diff --git a/docs/guides/experiments/README.md b/docs/guides/experiments/README.md new file mode 100644 index 0000000..122b114 --- /dev/null +++ b/docs/guides/experiments/README.md @@ -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 가 여기 해당하며, 각 편이 어느 단계가 그런지 적는다. diff --git a/docs/guides/experiments/a0-session-replication.md b/docs/guides/experiments/a0-session-replication.md new file mode 100644 index 0000000..2d4fa1a --- /dev/null +++ b/docs/guides/experiments/a0-session-replication.md @@ -0,0 +1,1261 @@ +# A-0 재현 가이드 — 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 직접 가른다 + +해설 문서: [`docs/experiment-00-session-replication.md`](../../experiment-00-session-replication.md) · +증거 원문: [`docs/evidence/session-replication/`](../../evidence/session-replication/) + +## 이 가이드가 끝나면 + +당신 터미널에서 이것들을 **직접 본다.** + +| 보게 되는 것 | 어디서 | +|---|---| +| 한 노드에서 만든 세션을 반대편이 갱신하는 것 | 상주 curl 파드 | +| 반대편에서 로그아웃하면 원래 노드가 `400` 을 주는 것 | 같은 파드 | +| 로그인을 받은 노드의 캐시만 늘고 **반대편은 `+0`** 인 것 | Prometheus | +| 캐시 합계와 DB 총계가 정확히 맞는 것 (`7 + 5 = 12`) | Prometheus · PostgreSQL | +| 반대편 노드가 실제로 날린 `SELECT` · `UPDATE` 문장 | PostgreSQL 문장 로그 | +| 그 트랜잭션 안의 `SET LOCAL synchronous_commit TO OFF` | 같은 로그 | + +## 전제 + +- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다. +- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다 + (kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다). +- 네임스페이스는 전부 `keycloak-lab` 이다. +- 터미널 **두 개**를 열어 두면 편하다. 하나는 탐침 파드 셸용(붙잡고 있어야 한다), + 하나는 관찰용. +- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다. + +## 주의 — 이건 상태를 부수는 실험이다 + +세션 테이블을 비우고, StatefulSet 을 재시작하고, PostgreSQL 의 문장 로깅을 +켠다. **실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 +적어 두었다. 중간에 그만두려면 [5. 복구](#5-복구) 의 두 명령이면 된다. + +## 표시 규약 + +| 표시 | 뜻 | +|---|---| +| **실측** | 2026-09-04 09:52–10:14 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 | +| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 | +| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 | + +IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지 +않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의 +실제 값이다. + +--- + +# 0. 왜 이 실험을 먼저 하는가 + +앞 단계에서 Keycloak 2노드 클러스터를 세우고 로그에서 `ISPN000094` 멤버 2개를 +확인했다. **거기서 멈추면 「클러스터가 떴다」까지만 아는 것**이다. 그 위에 +장애를 주입해 봐야 무엇이 무엇 때문에 깨졌는지 해석할 수 없다. + +기준선이 없으면 이런 추론을 하게 된다. + +> 7800 을 막았더니 세션이 깨졌다 → 역시 세션은 7800 으로 복제되는구나 + +**「클러스터가 형성됐다」와 「세션이 복제된다」는 다른 얘기다.** 이 실험은 그 둘을 +가른다. 갈라야 할 것은 이것이다. + +``` + 두 노드가 같은 답을 한다 + │ + ├── (a) Infinispan 이 세션을 복제했다 ← 통념 + │ + └── (b) 두 노드가 같은 PostgreSQL 을 본다 ← 확인할 것 +``` + +**(a) 와 (b) 는 겉보기 결과가 같다.** 「반대편에서도 된다」만 보면 구별이 안 된다. +그래서 이 가이드는 네 개의 시험을 순서대로 한다. + +| 시험 | 무엇을 가르나 | +|---|---| +| **0** 교차 노드 사용 | 반대편이 그 세션을 쓸 수 있는가 (여기까지는 (a)·(b) 구별 안 됨) | +| **0b** 캐시 계수기 델타 | 로그인 하나에 반대편 캐시가 **움직이는가** | +| **0c** 엔트리 소유 | 엔트리가 **어느 노드에** 생기는가 | +| **0d** SQL 포획 | 반대편이 **정말 DB 를 읽는가** — 추론을 관측으로 바꾼다 | + +--- + +# 1. 기준선 — 세션을 만들기 전에 + +넓은 것부터 좁혀 간다. + +``` +노드 → 파드 → 클러스터 뷰(로그) → 디스커버리(DB) → DB 세션 수 → 노드별 캐시 → 탐침 고르기 +``` + +## 1-1. 노드와 파드 + +**확인** +```bash +sudo kubectl get nodes +``` +**형태** +``` +NAME STATUS ROLES AGE VERSION +kc-lab-1 Ready control-plane,master 12d v1.33.x+k3s1 +kc-lab-2 Ready 12d v1.33.x+k3s1 +``` + +둘 다 `Ready` 여야 한다. 여기서부터 어긋나면 이 실험의 결과는 전부 무의미하다. + +**확인** +```bash +sudo kubectl -n keycloak-lab get pods -o wide +``` + +**어디를 봐야 하는가** + +- `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0` +- **`NODE` 가 서로 다르다** — 같은 노드에 있으면 이 실험은 성립하지 않는다 +- `postgres` 가 어느 노드에 있는지도 적어 둔다. A-2·A-3 에서 이게 중요해진다 + +**이 결과가 의미하는 것** — 원래 실행에서는 `keycloak-0` 이 `kc-lab-2`, +`keycloak-1` 이 `kc-lab-1` 이었다. **파드 번호와 노드 번호가 어긋난다.** +이걸 헷갈리면 뒤에서 엉뚱한 노드를 뒤지게 된다. + +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" +``` +**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt) +``` +=== 대상 === + keycloak-0 10.42.1.43 kc-lab-2 + keycloak-1 10.42.0.35 kc-lab-1 +``` + +## 1-2. 클러스터 뷰 — 로그가 말하는 것 + +**확인** +```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 +``` +**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt) +``` +2026-09-04 00:52:09,294 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)] +``` + +**어디를 봐야 하는가** + +``` +[keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843] + └── 코디네이터 ──┘ │ │ └────── 멤버 목록 ──────┘ + │ └─ 멤버 수 + └─ 뷰 ID (바뀔 때마다 1 증가) +``` + +**이 결과가 의미하는 것** — 멤버가 2 다. **그리고 이 줄이 이 실험에서 증명하는 +것은 여기까지다.** 「멤버가 둘」은 「세션이 오간다」가 아니다. 이 문서 전체가 +그 간극을 재는 일이다. + +## 1-3. 디스커버리 — 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" +``` +**실측** +``` + name | ip | coord +------------------+-----------------+------- + keycloak-1-48749 | 10.42.0.35:7800 | t + keycloak-0-30843 | 10.42.1.43:7800 | f +(2 rows) +``` + +**어디를 봐야 하는가** — **`coord` 열에 `t` 가 정확히 하나.** + +**이 결과가 의미하는 것** — 두 노드가 서로를 찾을 수 있고 코디네이터가 하나로 +합의되어 있다. 이 테이블은 **「지금 등록되어 있다」**만 말한다. + +## 1-4. 세션이 사는 테이블을 먼저 본다 + +**확인** +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c "\dt" +``` +**실측** — 해설 문서 8-2 절 +``` + public | auth_session | table | keycloak + public | jgroups_ping | table | keycloak + public | offline_client_session | table | keycloak + public | offline_user_session | table | keycloak + public | revoked_token | table | keycloak + public | root_auth_session | table | keycloak +``` + +**어디를 봐야 하는가** — **`USER_SESSION` 테이블이 없다.** + +### 개념 — 온라인 세션이 `OFFLINE_` 테이블에 들어간다 + +`persistent-user-sessions`(Keycloak 26 기본값)는 새 테이블을 만들지 않고 +**기존 오프라인 세션 테이블을 재사용**한다. `offline_flag` 컬럼으로 구분한다. + +| `offline_flag` | 의미 | +|---|---| +| **`'0'`** | **온라인 세션** (일반 로그인) | +| `'1'` | 오프라인 세션 (`offline_access`) | + +기본키가 `(user_session_id, offline_flag)` 복합키인 이유다 — 같은 세션 id 가 +온라인/오프라인 두 행으로 존재할 수 있다. + +**이름이 내용을 배신하는 스키마다.** 운영에서 「온라인 세션이 DB 어디 있냐」를 +찾을 때 이걸 모르면 한참 헤맨다. 이 가이드의 모든 질의는 `offline_flag='0'` 이다. + +**확인** — 지금 몇 건인가 +```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 | 2 +``` + +**이 값을 적어 둔다.** 뒤에서 캐시 합계와 맞춰 볼 대조군이다. + +## 1-5. 노드별 캐시 엔트리 — 이 실험의 주 계기(計器) + +Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). **밖에서 Prometheus 에 +묻는 것이 가장 짧다.** 15초마다 이미 긁고 있다. + +**확인** +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' +``` + +한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이 +붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. + +**형태** +```json +{"status":"success","data":{"resultType":"vector","result":[ +{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757037600.123,"0"]}, +{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757037600.123,"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]' +``` + +**어디를 봐야 하는가** — `cache` 가 `sessions` 인 두 줄과 그 값. +`clientSessions` `work` 등 다른 캐시도 같이 나오므로 `cache` 라벨을 반드시 본다. + +> 중괄호를 URL 에 그대로 넣으면 `wget` 이 싫어할 수 있다. 그래서 이 가이드는 +> 쿼리에 라벨 필터를 걸지 않고 **받은 뒤에 거른다.** 통하는 환경이라면 +> `query=vendor_statistics_approximate_entries_unique{cache="sessions"}` 가 짧다. + +**이 결과가 의미하는 것** — 각 노드가 자기 `sessions` 캐시에 몇 개를 들고 있는지 +보고한다. **이 값 하나로 0b·0c 를 전부 판정한다.** + +## 1-6. 탐침을 고르는 법 — 왜 `refresh` 인가 + +**이 절을 건너뛰면 뒤의 숫자를 잘못 읽는다.** 원래 실행이 실제로 잘못 읽었다. + +첫 판본은 `userinfo` 로 재고 이렇게 보고했다. + +**실측** — 해설 문서 2-1 절 +``` +=== [4] keycloak-0 이 발급한 토큰을 keycloak-1 이 받는가 === + http_code=403 +``` + +**403 을 「복제 실패」로 읽을 뻔했다.** 발급 노드에도 같은 요청을 보내 보니 + +**실측** +``` +--- userinfo, scope 없음 --- + k0(발급노드) 403 + k1(반대편) 403 +--- 403 본문 --- +WWW-Authenticate: Bearer realm="master", error="insufficient_scope", + error_description="Missing openid scope" +``` + +**양쪽 다 403 이었다.** 원인은 복제가 아니라 요청에 `openid` scope 가 없다는 +것이었고, 오히려 **두 노드가 똑같이 답했다는 사실 자체가 일치의 증거**였다. + +> **원칙** — 반대편 노드의 응답은 발급 노드의 응답과 나란히 놓기 전까지 아무 +> 의미가 없다. **시험군만 재는 측정은 측정이 아니다.** + +탐침도 바꿔야 했다. + +| 탐침 | 하는 일 | 적합한가 | +|---|---|---| +| `userinfo` | 서명 검증 + scope 확인 | **아니다.** 세션을 몰라도 통과할 수 있다 | +| **`refresh_token` 그랜트** | 세션을 찾고, 살아 있는지 보고, 갱신 시각을 쓴다 | **그렇다** | + +**refresh 는 읽고 쓴다.** 그래서 「저 노드가 이 세션을 정말로 아는가」에 답한다. + +그리고 **refresh token 은 회전한다** — 한 번 쓰면 옛 것이 무효가 된다. 따라서 +**반대편 노드에 먼저 써야** 한다. 발급 노드에 먼저 쓰면 시험군에 쓸 토큰이 +사라진다. **대조군과 시험군의 순서가 강제된다.** + +## 1-7. 개수가 아니라 sid 로 추적한다 + +원래 실행은 관리 API 의 `active=2` 를 보고 판정하려다 실패했다. 스크립트 자체가 +로그인을 두 번 했기 때문이다(시험용 + 관리 API 호출용). + +> **개수는 실험 도구가 만든 잡음에 그대로 오염된다.** 토큰에서 `sid` 를 뽑아, +> 각 노드의 답에 **그 sid 가 있는지**를 본다. 개수가 몇이든 상관없다. + +`sid` 는 세 곳에서 같은 문자열이다. + +``` +JWT access_token 의 sid jiv3rVZi1VeaO07oVJkL_MYW + ↕ 같은 값 +DB user_session_id jiv3rVZi1VeaO07oVJkL_MYW + ↕ 같은 값 +Admin API 세션 목록의 id jiv3rVZi1VeaO07oVJkL_MYW +``` + +**장애를 추적할 때 이 값 하나로 토큰·DB·관리 API 를 꿰뚫을 수 있다.** + +--- + +# 2. 주입 ① — 출발점을 깨끗하게 만든다 + +여기부터 상태가 바뀐다. **세션이 전부 지워지고 두 파드가 재시작된다.** + +**되돌리기** — 되돌릴 수 없다. 지운 세션은 돌아오지 않는다. 실험대에서만 한다. + +## 2-1. 왜 비우고 시작하나 + +0b·0c 는 **증가분(델타)** 으로 판정한다. 시작값이 지저분해도 델타는 맞지만, +**「7 + 5 = 12」 같은 합계 대조는 시작값이 깨끗해야 읽힌다.** 그리고 남아 있는 +캐시 엔트리는 다음 함정을 부른다. + +> **DB 에서 직접 지우면 캐시는 남는다.** 원래 실행에서 정리하려고 +> `delete from offline_user_session` 만 했더니 **캐시 엔트리는 그대로 남아** +> 캐시 합계(19)와 DB 총계(15)가 어긋났다. 해설 문서 10-2 절이 그 기록이다. +> +> 운영에서도 같다. 세션을 지울 때는 관리 API(`logout-all`)를 쓰거나, +> **DB 를 건드렸다면 파드를 재시작**해야 한다. + +**그래서 두 가지를 같이 한다.** 하나만 하면 안 된다. + +## 2-2. 적용 + +**하기** — DB 를 비운다 +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "delete from offline_user_session" +``` +**형태** +``` +DELETE 12 +``` + +**하기** — 캐시를 비운다. StatefulSet 을 굴려 재시작한다 +```bash +date '+%H:%M:%S 재시작' +sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak +sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s +``` + +**어디를 봐야 하는가** — `rollout status` 가 **끝날 때까지 블록한다.** 돌아오면 +두 파드가 새로 떠 있다. + +**시각을 적어 둔다.** 나중에 Grafana 로 시계열을 볼 때 이 시각이 「캐시가 0 으로 +떨어진 절벽」이다. + +--- + +# 3. 주입이 실제로 걸렸는지 확인한다 + +**결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다.** + +## 3-1. 파드가 새로 떴고 IP 가 바뀌었다 + +**확인** +```bash +sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak +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" +``` + +**어디를 봐야 하는가** — `AGE` 가 방금이고 `RESTARTS` 가 `0`(새 파드다), +그리고 **IP 가 아까 적어 둔 값과 다르다.** + +**★ IP 를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿는다.** +그걸 「복제 실패」로 읽는 것이 이 실험에서 가장 하기 쉬운 실수다. + +## 3-2. 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 rows) +``` + +한 행도 없어야 한다. 남아 있으면 `delete` 가 실패했거나 그 사이 누가 로그인했다. + +## 3-3. 캐시가 비었나 — **양쪽 다** 본다 + +**확인** +```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]' +``` + +**어디를 봐야 하는가** — `cache":"sessions"` 인 **두 줄이 다 `0`.** + +**이 결과가 의미하는 것** — 이제 나오는 모든 증가분은 **내가 만든 것**이다. +한쪽만 확인하고 넘어가면, 원래 남아 있던 값을 나중에 「복제가 왔다」로 읽는다. + +> Prometheus 는 15초마다 긁는다. 재시작 직후에 물으면 **옛 값이 남아 있을 수 +> 있다.** 30초쯤 기다렸다가 다시 친다. A-2 의 기준선 기록에도 +> 「스크레이프 지연」 때문에 값이 한 박자 늦은 자국이 남아 있다. + +--- + +# 4. 관찰 — 네 개의 시험 + +## 4-0. 상주 탐침 파드를 띄운다 + +### 왜 파드를 띄우나 + +- Keycloak 이미지에 `curl` 이 없다 → 파드 안에서는 못 친다 +- 토큰을 단계 사이로 넘겨야 한다 → **한 셸 안에서** 다 해야 한다 +- Service 로 가면 **어느 노드가 처리했는지 알 수 없다** → 파드 IP 로 직접 친다. + 이 실험의 질문 자체가 「어느 노드인가」다 + +**하기** +```bash +sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \ + --image=curlimages/curl:8.11.1 \ + --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 -- sh +``` + +**되돌리기** — 셸에서 `exit` 하면 `--rm` 이 파드를 지운다. + +> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도 +> 셸 히스토리에도 남지 않는다(히스토리에는 `$(...)` 문자열만 남는다). +> 존재와 길이만 확인하고 싶으면 밖에서: +> ```bash +> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \ +> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c +> ``` +> **실측** — `19` + +여기부터 프롬프트가 `/ $` 로 바뀐다. **파드 안 셸**이다. + +**확인** — 환경변수가 제대로 들어왔나 +```sh +echo "K0=$K0 K1=$K1 PW길이=${#PW}" +``` +**형태** +``` +K0=10.42.1.43 K1=10.42.0.35 PW길이=19 +``` +`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 나가서 다시 띄운다. + +## 4-1. 시험 0 — 교차 노드 세션 사용 + +### [1] `keycloak-0` 에서 로그인한다. 이 노드가 세션의 출생지다 + +**하기** +```sh +TOK=/realms/master/protocol/openid-connect/token +curl -s -X POST "http://$K0:8080$TOK" \ + -d grant_type=password -d client_id=admin-cli \ + -d username=admin -d "password=$PW" +``` + +**형태** — 한 줄 JSON 이 나온다. 한 번은 통째로 본다 +```json +{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800, + "refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"} +``` + +**어디를 봐야 하는가** — `expires_in` 과 `refresh_expires_in`. + +**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt) +``` +=== [1] keycloak-0 에서 로그인 === + sid jiv3rVZi1VeaO07oVJkL_MYW + sub None + iss https://auth.hyeonworks.com/realms/master + access 수명 60초 + refresh 수명 1800초 typ=Refresh + refresh jti 7669cc49-4778-851f-3c49-65f76964ae8e +``` + +**이 결과가 의미하는 것** — **access token 은 60초짜리고 그동안은 서버에 안 +물어본다.** 그래서 탐침이 access token 이면 안 된다. `iss` 가 파드 IP 가 아니라 +`https://auth.hyeonworks.com/...` 인 것은 `KC_HOSTNAME` 이 그렇게 잡혀 있기 +때문이고, 정상이다. + +> **`sub` 이 없다.** `admin-cli` 에 `scope` 없이 direct grant 를 하면 나오는 +> 클레임은 `azp, exp, iat, iss, jti, scope, sid, typ` 뿐이다(**실측**, +> 해설 문서 8-4). OIDC 가 아니라 순수 OAuth2 액세스 토큰이라 `sub` 이 안 붙는다. +> 1-6 의 `userinfo` 403 과 **같은 원인**이다. + +### [2] 토큰과 sid 를 변수에 담는다 + +**하기** +```sh +R=$(curl -s -X POST "http://$K0:8080$TOK" \ + -d grant_type=password -d client_id=admin-cli \ + -d username=admin -d "password=$PW") +RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') +AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') +echo "refresh=${#RT}자 access=${#AT}자" +``` +**형태** +``` +refresh=1187자 access=2043자 +``` +길이가 `0자` 로 나오면 로그인이 실패한 것이다. `echo "$R"` 로 에러 본문을 본다. + +**하기** — JWT 의 가운데 토막이 클레임이다. 여기서 sid 를 읽는다 **미검증** +```sh +echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo +``` +**형태** +```json +{"exp":1757037660,"iat":1757037600,"jti":"...","iss":"https://auth.hyeonworks.com/realms/master", + "typ":"Bearer","azp":"admin-cli","sid":"jiv3rVZi1VeaO07oVJkL_MYW","scope":"profile email"} +``` +```sh +SID=$(echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \ + | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p') +echo "SID=$SID" +``` +**`SID` 를 적어 둔다.** 밖에서 DB 를 뒤질 때 이 값이 필요하다. + +> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다). +> `sid` 는 앞쪽에 있어서 대개 보인다. + +### [3] 같은 sid 가 두 노드 모두에서 보이는가 + +관리 API 의 세션 목록을 **두 노드에 똑같이** 묻는다. 먼저 `admin-cli` 의 내부 +id 가 필요하다. + +**확인** — 응답을 한 번 그대로 본다 +```sh +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","name":"${client_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":"` 를 고른다. +> `tr ',' '\n' | grep -m1` 은 **첫 번째** 것을 고르므로 안전하다. + +**하기** — 같은 질문을 두 노드에 던지고 sid 가 있는지만 본다 **미검증** +```sh +for H in "$K0" "$K1"; do + echo -n "$H : " + curl -s -H "Authorization: Bearer $AT" \ + "http://$H:8080/admin/realms/master/clients/$CID/user-sessions?max=100" \ + | grep -c "$SID" +done +``` +**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt) +``` +=== [3] 같은 sid 가 두 노드 모두에서 보이는가 === + keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔ + ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000 + keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔ + ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000 +``` + +**어디를 봐야 하는가** — 두 줄 다 `1`(또는 그 이상). **개수가 아니라 sid 의 +유무다.** 세션이 2개인 것은 실험 도구가 만든 잡음이고, 판정에 안 쓴다. + +**이 결과가 의미하는 것** — 두 노드가 같은 세션을 안다. **여기까지는 (a) 와 (b) +를 구별하지 못한다.** 다음 절부터가 진짜다. + +### [5] 시험군 — `keycloak-0` 이 발급한 refresh token 을 `keycloak-1` 에 쓴다 + +**refresh 는 회전하므로 반대편에 먼저 쓴다.** 순서가 강제된다(1-6). + +**하기** +```sh +R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \ + -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT") +echo "$R" | tail -1 +RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') +``` +**실측** +``` +=== [5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용 === + HTTP 200 ← 기대대로 + 새 토큰의 sid → 동일 ✔ +``` + +**어디를 봐야 하는가** — `200`, 그리고 **새 토큰의 sid 가 같은 값**인 것. +sid 가 바뀌었다면 세션을 이어받은 게 아니라 새로 만든 것이다. + +> **매번 `RT` 를 다시 담는다.** 옛 것을 계속 쓰면 나중에 나오는 400 이 +> 무효화 때문인지 재사용 때문인지 알 수 없게 된다. + +### [6][7] 무효화가 반대 방향으로도 전파되는가 + +**하기** +```sh +curl -s -o /dev/null -w '%{http_code}\n' -X POST \ + "http://$K1:8080/realms/master/protocol/openid-connect/logout" \ + -d client_id=admin-cli -d "refresh_token=$RT" + +curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \ + -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT" +``` +**실측** +``` +=== [6] keycloak-1 을 통해 로그아웃 === + http_code=204 + +=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) === + HTTP 400 ← 기대대로 + error invalid_grant + error_description Session not active +``` + +**이 결과가 의미하는 것** — `keycloak-1` 에서 로그아웃하면 `keycloak-0` 에서도 +갱신이 막힌다. **무효화가 전파된다.** + +> **이 `400` 을 기억해 둔다.** [A-1](a1-jgroups-transport-block.md) 에서 7800 을 +> 막으면 **바로 이 자리가 `200` 으로 바뀐다.** 그게 A-1 의 결론이다. + +### [8] DB 를 직접 본다 + +`exit` 으로 파드에서 나온다. 밖에서: + +**확인** +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "select user_session_id, offline_flag, created_on, last_session_refresh + from offline_user_session where user_session_id='jiv3rVZi1VeaO07oVJkL_MYW'" +``` +`jiv3rVZi1VeaO07oVJkL_MYW` 자리에 위에서 적어 둔 **당신의 `SID`** 를 넣는다. + +**실측** +``` +=== [8] PostgreSQL 에서 그 sid 를 직접 확인 === + 대상 sid: jiv3rVZi1VeaO07oVJkL_MYW + 행 없음 — 로그아웃으로 삭제되었다 + + 전체 세션 수: 1 +``` + +**이 결과가 의미하는 것** — **`sid` 는 JWT 안에만 있는 값이 아니다.** +`OFFLINE_USER_SESSION.user_session_id` 컬럼에 **문자 그대로** 들어 있다. +로그아웃과 함께 행이 사라졌다. + +**여기까지 네 가지가 모두 기대대로다.** + +| | 확인된 것 | +|---|---| +| 조회 | 같은 sid 가 양쪽에서 보인다 | +| **쓰기** | `keycloak-0` 의 refresh token 을 `keycloak-1` 이 받아 갱신했고 **sid 가 유지된다** | +| **역방향 무효화** | `keycloak-1` 의 로그아웃이 `keycloak-0` 의 갱신을 막았다 | +| 영속 | 로그아웃과 함께 DB 행이 사라졌다 | + +**그런데 이것으로 「Infinispan 이 복제했다」고 말할 수 없다.** 두 노드가 같은 +PostgreSQL 을 보면 캐시를 아예 꺼도 같은 결과가 나온다. + +## 4-2. 시험 0b — 복제인가, 같은 DB 를 본 것인가 + +**가르는 방법: 로그인 한 번을 사이에 두고 양쪽 노드의 캐시 계수기를 잰다.** + +| | (a) 복제라면 | (b) 같은 DB 라면 | +|---|---|---| +| 로그인을 받은 노드 | 는다 | 는다 | +| **반대편 노드** | **같이 는다** | **안 움직인다** | + +**하기** — 전값을 잰다 +```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]' +``` + +**하기** — `keycloak-0` 에만 로그인 한 번. 탐침 파드를 다시 띄워서 친다 +```bash +sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \ + --image=curlimages/curl:8.11.1 --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 -- sh +``` +```sh +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" +exit +``` +**형태** +``` +200 +``` + +**30초 기다린다.** Prometheus 가 다음 스크레이프를 할 때까지다. 그리고 후값을 +같은 명령으로 잰다. + +**실측** — [`02-cache-delta.txt`](../../evidence/session-replication/02-cache-delta.txt) +``` +=== keycloak-0 (로그인을 받은 노드) === + 계수기 캐시 전 후 증가 + rpc.replication_count clientSessions 1 1 +0 + rpc.replication_count sessions 1 1 +0 + approximate_entries_unique clientSessions 1 2 +1 ← + approximate_entries_unique sessions 1 2 +1 ← + hits clientSessions 2 2 +0 + hits sessions 2 2 +0 + misses clientSessions 2 3 +1 ← + misses sessions 3 4 +1 ← + stores clientSessions 2 3 +1 ← + stores sessions 2 3 +1 ← + +=== keycloak-1 (아무 요청도 받지 않은 노드) === + 계수기 캐시 전 후 증가 + rpc.replication_count clientSessions 7 7 +0 + rpc.replication_count sessions 7 7 +0 + approximate_entries_unique clientSessions 0 0 +0 + approximate_entries_unique sessions 0 0 +0 + hits clientSessions 4 4 +0 + hits sessions 4 4 +0 + misses clientSessions 0 0 +0 + misses sessions 0 0 +0 + stores clientSessions 1 1 +0 + stores sessions 1 1 +0 +``` + +**어디를 봐야 하는가** — **`keycloak-1` 열이 전부 `+0`.** 엔트리도 0, 저장도 0. +그리고 `keycloak-1` 의 `sessions` 엔트리는 **처음부터 끝까지 0** 이다. + +**이 결과가 의미하는 것** — **(b) 다.** 복제였다면 `keycloak-1` 의 +`approximate_entries_unique` 나 `stores` 가 최소한 하나는 움직였어야 한다. + +> `rpc.replication_count` 가 `1`·`7` 로 0 이 아닌 것에 속으면 안 된다. +> **이 계수기는 세션 캐시만의 것이 아니다.** 클러스터가 다른 용무로 주고받은 +> 것까지 센다. 판정은 **증가분이 0** 이라는 사실로 한다. + +> 다른 계수기 이름을 같이 보고 싶으면 쿼리 이름만 바꿔서 같은 명령을 친다 — +> `vendor_statistics_stores` · `vendor_statistics_hits` · +> `vendor_statistics_misses` · `vendor_rpc_manager_replication_count`. + +## 4-3. 시험 0c — 엔트리는 어느 노드에 있는가 + +0b 의 결과에는 두 가지 설명이 남아 있다. + +| | | +|---|---| +| (a) **분산 캐시 + owners=1** | 일관 해싱으로 흩어지는데 이번 건이 우연히 `keycloak-0` 에 떨어졌다 | +| (b) **로컬 캐시** | 각 노드는 자기가 처리한 것만 캐시한다 | + +**반대편 노드에 로그인을 몰아주면 갈린다.** (a) 라면 어느 쪽에 요청하든 +엔트리는 양쪽에 흩어진다. (b) 라면 **요청을 받은 노드에서만** 는다. + +**하기** — 탐침 파드 안에서, `keycloak-1` 에 5회 +```sh +for i in 1 2 3 4 5; do + curl -s -o /dev/null -w '%{http_code} ' -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" +done; echo +``` +**형태** +``` +200 200 200 200 200 +``` + +30초 기다렸다가 엔트리를 잰다. 그 다음 `$K1` 을 `$K0` 로 바꿔 5회 더 하고 +다시 잰다. + +**실측** — [`03-cache-ownership.txt`](../../evidence/session-replication/03-cache-ownership.txt) +``` + keycloak-0 = 10.42.1.43 (kc-lab-2) + keycloak-1 = 10.42.0.35 (kc-lab-1) + +단계 k0 entries k1 entries +시작 2.0 0.0 +keycloak-1 에 로그인 5회 2.0 5.0 +keycloak-0 에 로그인 5회 7.0 5.0 + +=== 대조: PostgreSQL 에는 몇 건인가 === + online 세션 12 +``` + +**어디를 봐야 하는가** — 대각선이다. **한 번에 한 쪽만 는다.** + +**확인** — DB 총계와 맞춰 본다 +```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'" +``` + +**이 결과가 의미하는 것** — **(b) 다.** 그리고 **7 + 5 = 12** 로 DB 총계와 정확히 +맞는다. 모든 세션이 DB 에 있고, 각각은 **자기를 만든 노드 한 곳에만** 캐시되어 +있다. 어느 엔트리도 두 번 세어지지 않았다 — 복제가 없다는 뜻이다. + +> **그래프로 같은 사실을 본다.** Grafana Explore 에서 +> `vendor_statistics_approximate_entries_unique{cache="sessions"}`, +> Legend `{{pod}} on {{node}}`. 원래 실행의 그림이 +> [`session-cache-entries-per-pod.png`](../../evidence/session-replication/session-cache-entries-per-pod.png) +> 이고, **파란 선(keycloak-1)이 0 에 붙어 있는 동안 초록 선(keycloak-0)만 14 까지 +> 오른다.** 중간의 절벽이 2-2 의 재시작이다. + +> **캐시 설정은 파일에서 읽을 수 없다.** 파드의 `/opt/keycloak/conf/cache-ispn.xml` +> 은 `` 뿐이고, +> Keycloak 26 은 캐시를 **코드에서** 만든다. 그래서 위 결론은 설정을 읽어서가 +> 아니라 **동작을 측정해서** 얻은 것이다. + +## 4-4. 시험 0d — 주입 ②: SQL 을 직접 잡는다 + +0b·0c 까지는 **추론**이다. 「`keycloak-1` 메모리에 없는데 쓸 수 있으니 DB 에서 +읽었을 것이다」 — 그럴듯하지만 **SQL 을 본 적은 없다.** + +**PostgreSQL 문장 로깅을 몇 초만 켠다.** 여기부터 두 번째 주입이다. + +**되돌리기** — 먼저 읽어 둔다 +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "alter system reset log_statement" -c "alter system reset log_line_prefix" \ + -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 "alter system set log_line_prefix='%m [%p] %h '" \ + -c "select pg_reload_conf()" +``` + +**`%h` 가 핵심이다.** 클라이언트 IP 를 로그 줄 앞에 남긴다. 이게 없으면 +**어느 파드가 보낸 질의인지 구별할 수 없다.** + +**확인** — 실제로 켜졌나 +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "show log_statement" -c "show log_line_prefix" +``` +**실측** — [`04-read-path-sql.txt`](../../evidence/session-replication/04-read-path-sql.txt) +``` + log_statement = all + log_line_prefix = %m [%p] %h +``` + +`log_statement` 가 아직 `none` 이면 `pg_reload_conf()` 가 안 돈 것이다. +`alter system` 은 `postgresql.auto.conf` 에 쓸 뿐이고, **reload 를 해야 적용된다.** + +### 요청을 딱 한 번 보낸다 + +**하기** — 탐침 파드 안에서. `keycloak-0` 에서 만든 세션을 `keycloak-1` 에 갱신 +```sh +TOK=/realms/master/protocol/openid-connect/token +R=$(curl -s -X POST "http://$K0:8080$TOK" \ + -d grant_type=password -d client_id=admin-cli \ + -d username=admin -d "password=$PW") +RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') +SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \ + | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \ + | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p') +echo "SID=$SID" + +curl -s -o /dev/null -w '%{http_code}\n' -X POST "http://$K1:8080$TOK" \ + -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT" +``` +**실측** +``` +=== 요청 === + SID=jSt9GEPVQLJsO-1CeJjVgltg + K1_ENTRIES_BEFORE=5.0 + REFRESH_ON_K1=200 + K1_ENTRIES_AFTER=5.0 +``` + +**한 번만 보낸다.** 여러 번 보내면 로그에서 어느 트랜잭션이 어느 요청인지 +구별하기 어려워진다. + +### 로그를 뒤진다 + +**확인** — 먼저 `keycloak-1` 이 보낸 것만 본다. `%h` 가 남긴 IP 로 거른다 +```bash +sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \ + | grep "$K1" | grep 'LOG: execute' +``` + +**실측** — [`04-read-path-sql.txt`](../../evidence/session-replication/04-read-path-sql.txt) +``` + select puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID,...,puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where (puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID) in (($1,$2)) + select puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where puse1_0.USER_SESSION_ID=$1 and puse1_0.OFFLINE_FLAG=$2 for no key update of puse1_0 skip locked + select pcse1_0.CLIENT_ID,...,pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where (...) in (($1,$2,$3,$4,$5)) + select pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where ... for no key update of pcse1_0 skip locked + update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where CLIENT_ID=$3 and ... and VERSION=$8 + update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5 + SET LOCAL synchronous_commit TO OFF + COMMIT +``` + +**이 결과가 의미하는 것** — **추론이 관측이 되었다.** `keycloak-1` 은 세션을 +DB 에서 읽고, DB 에 쓴다. + +**확인** — 그 sid 를 언급한 줄을 누가 보냈나. 파라미터는 `DETAIL` 줄에 있다 +```bash +sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \ + | grep 'jSt9GEPVQLJsO-1CeJjVgltg' | cut -c1-120 +``` +**실측** +``` +2026-09-04 01:12:32.851 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg' +... +2026-09-04 01:12:34.934 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg' +2026-09-04 01:12:34.936 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0' +2026-09-04 01:12:34.944 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '131a9912-...', ... +2026-09-04 01:12:34.946 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '0', $4 = 'jSt9GEPVQLJsO-1CeJjVgltg', $5 = '0' +``` + +> **증거 파일에는 IP 자리에 `[keycloak-0]` `[keycloak-1]` 이 적혀 있다.** +> 원래 실행 스크립트가 `sed` 로 IP 를 파드 이름으로 바꿔 놓은 것이다. **당신 +> 화면에는 `10.42.0.35` 같은 IP 가 그대로 나온다.** 1-1 에서 적어 둔 값과 맞춰 +> 읽는다. + +**어디를 봐야 하는가** — **pid 가 다르다.** `81407` 은 `keycloak-0` 의 연결, +`81376` 은 `keycloak-1` 의 연결이다. pid 가 트랜잭션의 경계다. + +**확인** — 파드별 질의 건수 **미검증** +```bash +sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \ + | grep 'jSt9GEPVQLJsO-1CeJjVgltg' | grep -c "$K0" +sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \ + | grep 'jSt9GEPVQLJsO-1CeJjVgltg' | grep -c "$K1" +``` +**실측** +``` +=== 요약: 파드별 질의 건수 === + 6 [keycloak-1] + 6 [keycloak-0] +``` + +**sid 하나에 대해 `keycloak-0` 이 6건(로그인), `keycloak-1` 이 6건(갱신)을 날렸다.** + +### 곧바로 끈다 + +**하기** +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "alter system reset log_statement" -c "alter system reset log_line_prefix" \ + -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'` 을 켜 두면 로그가 폭주한다. + +### 덤: 캐시는 읽어도 채워지지 않는다 + +위 실측의 세 줄을 다시 본다. + +``` + K1_ENTRIES_BEFORE=5.0 + REFRESH_ON_K1=200 + K1_ENTRIES_AFTER=5.0 ← 갱신을 처리하고도 그대로 +``` + +**`keycloak-1` 은 남의 세션을 DB 에서 읽어 처리하고도 캐시에 담지 않았다.** + +0c 에서 세운 모델을 더 좁혀야 한다. + +> 캐시에 담기는 것은 **그 노드가 로그인시켜 만든 세션**뿐이다. +> 남의 세션은 **매번 DB 에서 읽는다.** + +로드밸런서가 세션을 만든 노드가 아닌 쪽으로 요청을 보내면 **매번 DB 를 친다.** +세션 어피니티(sticky session)가 정확성이 아니라 **성능** 문제인 이유가 이것이다. + +### 덤 2: jdbc-ping 하트비트가 그대로 보인다 + +같은 로그에 이것도 있다. + +**실측** +``` +01:12:37.551 pid=81369 | BEGIN +01:12:37.551 pid=81369 | DELETE from JGROUPS_PING WHERE address=$1 +01:12:37.552 pid=81369 | INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values (...) +01:12:37.553 pid=81369 | COMMIT +``` + +**디스커버리는 별도 연결(pid 가 다르다)에서 주기적으로 자기 행을 지우고 다시 +넣는다.** 세션 트래픽과 완전히 분리된 경로다 — **「디스커버리와 트랜스포트는 +다른 경로」가 로그에서 눈으로 확인된다.** [A-1](a1-jgroups-transport-block.md) 이 +이 둘을 갈라 끊는 실험이다. + +## 4-5. 그 13밀리초짜리 트랜잭션에 들어 있던 세 가지 + +**원래 질문들의 답이 절반쯤 여기 있다.** 위에서 잡은 SQL 을 다시 읽는다. + +### (1) 낙관적 락 — `VERSION` 컬럼 + +```sql +update OFFLINE_USER_SESSION + set LAST_SESSION_REFRESH=$1, VERSION=$2 + where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5 + ───────────── + 읽을 때의 버전과 같을 때만 쓴다 +``` + +읽은 뒤 다른 노드가 먼저 고쳤다면 `VERSION` 이 달라져 **`UPDATE` 가 0행을 +갱신하고 실패한다.** 잠금을 오래 잡지 않고 충돌을 사후에 검출하는 방식이다. +**리프레시 토큰 동시 갱신 경쟁(B-3)이 여기서 갈린다.** + +### (2) `FOR NO KEY UPDATE ... SKIP LOCKED` + +```sql +select VERSION from OFFLINE_USER_SESSION + where USER_SESSION_ID=$1 and OFFLINE_FLAG=$2 + for no key update of puse1_0 skip locked + ────────────── ──────────── + 키가 아닌 컬럼만 잠근다 잠긴 행은 건너뛴다 (기다리지 않는다) +``` + +| 절 | 뜻 | +|---|---| +| `FOR NO KEY UPDATE` | 행을 잠그되 **외래키 참조는 막지 않는다.** `FOR UPDATE` 보다 약해 경합이 준다 | +| **`SKIP LOCKED`** | 이미 잠긴 행을 **기다리지 않고 건너뛴다** | + +같은 세션에 동시 요청이 몰려도 **줄을 서지 않는다.** 대기 대신 낙관적 락 +실패로 처리한다 — 처리량을 위해 **지연 대신 재시도**를 고른 설계다. + +### (3) `SET LOCAL synchronous_commit TO OFF` — 내구성을 일부 포기한다 + +**같은 트랜잭션 안에서**, `COMMIT` 직전에 나온다. pid 로 경계를 확인했다. + +| | | +|---|---| +| 기본값 `on` | `COMMIT` 이 **WAL 이 디스크에 내려간 뒤** 돌아온다 | +| **`off`** | **WAL 플러시를 기다리지 않고** 즉시 돌아온다 | + +**확인** — 전역 설정은 다르다 +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "show synchronous_commit" +``` +**형태** +``` + synchronous_commit +-------------------- + on +``` + +**전역은 `on` 이고, Keycloak 이 세션 트랜잭션에만 `SET LOCAL` 로 끈다.** +`SET LOCAL` 은 그 트랜잭션이 끝나면 되돌아간다. + +**결과: PostgreSQL 이 갑자기 죽으면 직전 수백 밀리초의 세션 쓰기가 사라질 수 +있다.** 커밋했다고 응답해 놓고 없어진다. **버그가 아니라 설계된 트레이드오프**다. + +> **[A-3](a3-database-crash.md) 이 이 값을 실측한다.** 여기서 본 한 줄이 거기서 +> 「200 을 받았는데 DB 에 없는 sid 4건」으로 나타난다. + +--- + +# 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` 이 아니면 4-4 의 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 만 지우면 캐시가 남아 다음 실험의 기준선이 +어긋난다(2-1 의 함정). + +## 5-3. 원상복구 확인표 + +| 항목 | 명령 | 돌아왔을 때 | +|---|---|---| +| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` | +| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` | +| 디스커버리 | `psql -c "select name, ip, coord from jgroups_ping order by name"` | `coord = t` 가 **하나** | +| DB 세션 | `psql -c "select count(*) from offline_user_session"` | `0` | +| 캐시 | `vendor_statistics_approximate_entries_unique` | `sessions` 두 줄 다 `0` | +| 문장 로깅 | `psql -c "show log_statement"` | `none` | +| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod kc-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 kc-probe --ignore-not-found +``` + +--- + +# 6. 그래서 무엇이 세션을 공유하는가 + +``` + 로그인 (keycloak-0) + │ + ├──▶ PostgreSQL OFFLINE_USER_SESSION ← 진실의 원천. 양쪽이 본다 + │ + └──▶ keycloak-0 로컬 캐시 ← 자기 것만. 건너가지 않는다 + + keycloak-1 이 그 세션을 물으면 + │ + └──▶ 자기 캐시에 없음 → PostgreSQL 에서 읽는다 (캐시에 담지도 않는다) +``` + +| 계층 | 역할 | 노드 간 공유 | +|---|---|---| +| **PostgreSQL** | 진실의 원천 | **여기서 일어난다** | +| **Infinispan `sessions`** | 자기 노드가 처리한 세션의 룩어사이드 캐시 | **일어나지 않는다** | +| **Infinispan 클러스터** | 무효화 메시지, `work` 캐시 등 | 형성은 되어 있다 | + +이건 **Keycloak 26 의 의도된 설계**다. `persistent-user-sessions` 가 기본이 +되면서 DB 가 진실의 원천이 됐고, 세션 캐시는 **복제할 이유가 없어졌다.** + +### 개념 — 룩어사이드(lookaside) 캐시 + +``` + 읽기: 캐시 확인 → 없으면 DB → (Keycloak 은 남의 세션이면 담지도 않는다) + 쓰기: DB 에 쓴다 +``` + +캐시가 **DB 앞에 서 있되 DB 를 대체하지 않는** 구조다. 캐시를 통째로 날려도 +정확성은 유지되고 느려지기만 한다. + +**이 성질이 다음 실험들의 결과를 미리 결정한다.** + +| | 이 성질이 예측하는 것 | +|---|---| +| **읽을 때 DB 와 대조하지 않는다** | 무효화가 안 가면 캐시가 **낡은 답**을 준다 → A-1 | +| **쓰기는 대신하지 못한다** | DB 가 죽으면 캐시가 있어도 refresh 가 실패한다 → A-2 | +| **DB 가 진실의 원천이다** | 노드가 죽어도 세션은 살아남는다 → A-4 | + +--- + +# 막히면 + +전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다. + +| 증상 | 원인 | 확인 | +|---|---|---| +| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 띄우거나 Prometheus 에 묻는다 | +| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 를 다시 — 3-1 | +| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | 파드 안에서 `echo ${#PW}` — `0` 이면 `--env` 가 빈 값 | +| 반대편 응답만 보고 「복제 실패」로 읽었다 | **대조군이 없다** | 발급 노드에 같은 요청을 나란히 — 1-6 | +| `userinfo` 가 양쪽 다 `403` | **`openid` scope 가 없다.** 복제와 무관 | 본문의 `insufficient_scope` — 1-6 | +| 세션 개수가 계속 어긋난다 | **관리 API 호출도 세션을 만든다** | 개수 말고 **sid** 로 본다 — 1-7 | +| 캐시 합계와 DB 총계가 안 맞는다 | **DB 만 지우고 파드를 재시작 안 했다** | `rollout restart statefulset/keycloak` — 2-1 | +| 로그인했는데 지표가 안 움직인다 | Prometheus 스크레이프는 15초 간격 | 30초 기다렸다 다시 — 3-3 | +| `rpc.replication_count` 가 0 이 아니라 당황 | 세션 캐시만의 계수기가 아니다 | 절대값이 아니라 **증가분**으로 본다 — 4-2 | +| `CID` 가 엉뚱한 값이다 | `sed` 의 `.*` 가 탐욕적이라 **마지막** `"id"` 를 잡는다 | `tr ',' '\n' \| grep -m1 '"id"'` — 4-1 | +| 문장 로깅을 켰는데 SQL 이 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 — 4-4 | +| 로그에 어느 파드인지 안 나온다 | `log_line_prefix` 에 `%h` 가 없다 | `show log_line_prefix` — 4-4 | +| 다음 실험에서 postgres 로그가 폭주한다 | **문장 로깅을 껐는지 확인 안 했다** | `show log_statement` 가 `none` — 5-1 | + +--- + +# 왜 이 가이드에는 스크립트가 거의 없나 + +원래 실행은 네 개의 bash 스크립트로 했고, 그 과정에서 **관측 자체가 두 번 +틀렸다.** + +**하나는 스트림 유실이다.** `kubectl run --rm -i ... | grep` 로 받았더니 중간 +조각이 통째로 사라져, `keycloak-1` 의 스냅샷과 다음 마커가 함께 없어졌다. +전값이 0 으로 잡히면서 **가짜 델타**가 만들어졌다. + +**실측** — 해설 문서 10-1 절 +``` +###BEFORE_K1 ← 여기 있어야 할 지표 20줄과 +http_code=200 다음 마커 ###LOGIN 이 통째로 사라졌다 +###AFTER_K0 +``` + +이때 리포트는 `keycloak-1` 이 `+9`, `+7` 증가한 것처럼 보였다. **없는 복제가 +있는 것처럼 보이는, 가장 나쁜 종류의 오류다.** + +**다른 하나는 중첩 인용이다.** `ssh host '... $VAR ...'` 안에 다시 `sh -c "..."` +를 넣으면 인용이 세 겹이 되어 치환이 조용히 깨진다. 첫 시도에서 파드 IP 가 빈 +문자열이 되어 아무 출력도 나오지 않았다. + +| 고친 방법 | | +|---|---| +| 파드 안에서 파일로 모으고 마지막에 `cat` 한 번 | 스트리밍 중 유실을 없앤다 | +| 스냅샷이 비면 **경고를 출력**한다 | 조용히 0 으로 계산되는 것을 막는다 | +| 스크립트 파일로 만들어 옮긴다 | 인용이 한 겹으로 준다 | + +> **계측 코드는 자기가 실패했는지 스스로 말해야 한다.** + +그래서 이 가이드는 관찰을 **한 번에 하나씩 손으로** 친다. 값이 이상하면 그 +자리에서 보이고, 다시 칠 수 있고, 무엇을 봤는지 남는다. + +--- + +# 다음 + +이제 **틀릴 수 있는 예측**을 세울 수 있다. 예측이 빗나가면 그것이야말로 배울 +거리다. + +| 실험 | A-0 이 세운 예측 | 근거 | +|---|---|---| +| [A-1](a1-jgroups-transport-block.md) 7800 차단 | **세션 공유는 안 깨진다.** 대신 무효화 전파가 깨진다 | 세션은 7800 으로 오가지 않는다 — 4-2·4-3 | +| [A-2](a2-database-loss.md) DB 정지 | **즉시 전면 장애.** 캐시에 있는 세션도 못 쓴다 | DB 가 진실의 원천이고 refresh 는 쓰기다 — 4-4 | +| [A-3](a3-database-crash.md) DB 강제 종료 | 직전 수백 ms 의 쓰기가 **사라진다** | `SET LOCAL synchronous_commit TO OFF` — 4-5 | +| B-3 동시 갱신 경쟁 | 한쪽이 `VERSION` 검사에서 지고 재시도한다 | 낙관적 락 — 4-5 | +| A-4 노드 상실 | **세션은 살아남는다.** 죽은 노드의 캐시만 사라진다 | 룩어사이드 — 6절 | + +특히 A-1 은 **직관과 어긋나는 예측**이다. 「클러스터 포트를 막으면 세션이 +깨진다」가 상식이지만, 이 기준선이 맞다면 안 깨져야 한다. diff --git a/docs/guides/experiments/a1-jgroups-transport-block.md b/docs/guides/experiments/a1-jgroups-transport-block.md new file mode 100644 index 0000000..fc34c65 --- /dev/null +++ b/docs/guides/experiments/a1-jgroups-transport-block.md @@ -0,0 +1,1092 @@ +# A-1 재현 가이드 — 7800 을 끊고 무엇이 깨지는지 직접 본다 + +해설 문서: [`docs/experiment-a1-jgroups-transport-block.md`](../../experiment-a1-jgroups-transport-block.md) · +증거 원문: [`docs/evidence/a1-jgroups-transport-block/`](../../evidence/a1-jgroups-transport-block/) + +## 이 가이드가 끝나면 + +당신 터미널에서 이것들을 **직접 본다.** + +| 보게 되는 것 | 어디서 | +|---|---| +| NetworkPolicy 를 걸었는데 클러스터가 안 깨지는 상태 | `conntrack -L` · `vendor_cluster_size` | +| `coord = t` 가 두 줄인 split brain | PostgreSQL `JGROUPS_PING` | +| 분단인데도 교차 노드 refresh 가 `200` | 임시 curl 파드 | +| 로그아웃했는데 반대편이 `200` 을 주는 상태 | 같은 파드 | +| 분단된 노드가 스스로 Service 에서 빠지는 것 | `endpointslice` | +| 90초 만에 자동으로 다시 붙는 것 | `merge3_get_num_merge_events` | + +## 전제 + +- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다. +- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다 + (kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다). +- `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. conntrack 은 **두 노드 모두에서** 봐야 한다. +- 터미널 **두 개**를 열어 두면 편하다. 하나는 임시 curl 파드용(붙잡고 있어야 한다), + 하나는 관찰용. + +## 주의 — 이건 상태를 부수는 실험이다 + +Keycloak 클러스터를 실제로 분단시키고 파드를 재시작한다. **실험대에서만 한다.** +전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적어 두었다. +중간에 그만두려면 [5. 복구](#5-복구) 의 첫 명령 하나면 된다. + +## 표시 규약 + +| 표시 | 뜻 | +|---|---| +| **실측** | 2026-09-04 11:38–11:52 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 | +| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 | +| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 | + +IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지 +않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의 +실제 값이다. + +--- + +# 0. 왜 이 실험을 하는가 + +A-0 이 「세션은 Infinispan 복제가 아니라 PostgreSQL 로 공유된다」를 측정했다. +그런데 Keycloak 24 이전 자료는 「세션은 7800 으로 복제된다」고 말한다. + +| | 예측 | +|---|---| +| 통념 | 7800 을 막으면 **세션 공유가 깨진다** | +| A-0 모델 | 7800 을 막아도 **안 깨진다** | + +둘 중 하나는 틀렸고, **7800 만 끊어 보면 판정된다.** 그게 이 실험이다. + +핵심은 **두 가지를 분리해서 끊는 것**이다. + +``` + 디스커버리 노드가 서로를 어떻게 찾는가 → PostgreSQL 의 JGROUPS_PING 테이블 + 트랜스포트 실제로 어떻게 말하는가 → TCP 7800 +``` + +트랜스포트만 막으면 **DB 에는 둘 다 등록되어 있는데 메시지는 안 가는 상태**가 +된다. 단일 노드에서는 만들 수 없는 고장이고, 이 실험대가 VM 두 대인 이유다. + +--- + +# 1. 기준선 — 아무것도 넣기 전에 + +**시험군만 재는 측정은 측정이 아니다.** 차단 후에 볼 것을 차단 전에 **똑같은 +명령으로** 먼저 봐 둔다. 그래야 나중에 「원래 그랬던 것」과 「내가 바꾼 것」이 +구별된다. + +넓은 것부터 좁혀 간다. + +``` +노드 → 파드 → 정책 → 클러스터 뷰(로그) → 디스커버리(DB) → 지표(Prometheus) → 대조군 시험 +``` + +## 1-1. 노드와 파드 + +**확인** +```bash +sudo kubectl get nodes +``` +**형태** +``` +NAME STATUS ROLES AGE VERSION +kc-lab-1 Ready control-plane,master 12d v1.33.x+k3s1 +kc-lab-2 Ready 12d v1.33.x+k3s1 +``` + +둘 다 `Ready` 여야 한다. 여기서부터 어긋나면 이 실험의 결과는 전부 무의미하다. + +**확인** +```bash +sudo kubectl -n keycloak-lab get pods -o wide +``` +**형태** +``` +NAME READY STATUS RESTARTS AGE IP NODE +bff-... 1/1 Running 0 3d 10.42.0.41 kc-lab-1 +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 +oauth2-proxy-... 1/1 Running 0 3d 10.42.0.44 kc-lab-1 +postgres-... 1/1 Running 0 5d 10.42.0.22 kc-lab-1 +redis-... 1/1 Running 0 3d 10.42.0.23 kc-lab-1 +``` + +**어디를 봐야 하는가** + +- `READY` 가 둘 다 `1/1` +- **`RESTARTS` 가 `0`** — 뒤에서 이 값이 오르면 주입이 엉뚱한 것을 건드린 것이다 +- **`NODE` 가 서로 다르다** — 같은 노드에 있으면 이 실험은 성립하지 않는다 + (파드 간 통신이 노드를 안 넘어간다) +- `IP` 두 개를 적어 둔다. 뒤에서 계속 쓴다 + +**이 결과가 의미하는 것** — `keycloak-0` 은 `kc-lab-2`, `keycloak-1` 은 `kc-lab-1` +에 있다. **파드 번호와 노드 번호가 어긋난다.** 뒤에서 conntrack 을 볼 때 이걸 +헷갈리면 엉뚱한 노드를 뒤지게 된다. + +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" +``` +**실측** +``` +10.42.1.43 10.42.0.35 +``` + +## 1-2. 기존 NetworkPolicy 가 없다는 것 + +**확인** +```bash +sudo kubectl -n keycloak-lab get networkpolicy +``` +**실측** — [`01-baseline-cluster.txt`](../../evidence/a1-jgroups-transport-block/01-baseline-cluster.txt) +``` +No resources found in keycloak-lab namespace. +``` + +**왜 확인하나** — 이미 정책이 하나라도 걸려 있으면 결과가 그것과 섞인다. +NetworkPolicy 는 **합집합으로 허용**되므로 두 개가 겹치면 무엇이 열려 있는지 +한눈에 안 보인다. 비어 있어야 「내가 넣은 것만이 원인」이라고 말할 수 있다. + +## 1-3. 클러스터 뷰 — 로그가 말하는 것 + +**확인** +```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-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)] + keycloak-1: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)] +``` + +**어디를 봐야 하는가** + +``` +[keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843] + └── 코디네이터 ──┘ │ │ └────── 멤버 목록 ──────┘ + │ └─ 멤버 수 + └─ 뷰 ID (바뀔 때마다 1 증가) +``` + +**이 결과가 의미하는 것** — 뷰 ID `5`, 멤버 `2`, 그리고 **양쪽이 완전히 같은 줄을 +찍고 있다.** 이게 「하나의 클러스터」다. 두 줄이 달라지면 그때가 분단이다. + +`keycloak-0-30843` 의 뒤 숫자는 JGroups 가 붙인 것이고 **파드가 재시작되면 +바뀐다.** 나중에 `keycloak-0-26403` 이 나오면 같은 파드의 새 인스턴스다. + +## 1-4. 디스커버리 — 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" +``` +**실측** +``` + name | ip | coord +------------------+-----------------+------- + keycloak-0-30843 | 10.42.1.43:7800 | f + keycloak-1-48749 | 10.42.0.35:7800 | t +(2 rows) +``` + +**어디를 봐야 하는가** — **`coord` 열에 `t` 가 정확히 하나.** + +**이 결과가 의미하는 것** — 두 노드가 서로를 찾을 수 있고, 코디네이터가 하나로 +합의되어 있다. 이 테이블은 **「지금 등록되어 있다」**만 말한다. 실제로 메시지가 +오가는지는 말하지 않는다 — 이 실험이 갈라놓을 지점이 정확히 여기다. + +> **셋이 서로 다른 것을 본다.** +> 로그 = 「그때 그렇게 보였다」, 테이블 = 「지금 등록되어 있다」, +> 지표 = 「지금 그 노드가 그렇게 안다」. A-1 에서 이 셋이 갈린다. + +## 1-5. 지표 — 각 노드가 자기가 아는 멤버 수를 말한다 + +Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 그래서 **밖에서 +Prometheus 에 묻는 것이 가장 짧다.** 15초마다 이미 긁고 있다. + +**확인** +```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":[1757037600.123,"2"]}, +{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757037600.123,"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]' +``` +`jq` 가 깔려 있다면 이쪽이 낫다. **미검증** +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \ + | jq -r '.data.result[] | "\(.metric.pod) \(.metric.node) \(.value[1])"' +``` + +**어디를 봐야 하는가** — **결과가 두 줄이고, 값이 둘 다 `2`.** + +**이 결과가 의미하는 것** — 두 노드가 각각 자기가 아는 멤버 수를 보고한다. +분단되면 **한쪽만 1 이 될 수도 있다.** 한 노드만 보면 분단을 놓친다. + +JGroups 카운터도 지금 0 인 것을 봐 둔다. 나중에 오르는지 보려면 지금 값이 필요하다. + +**확인** +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events' +``` +**실측** — [`02-control-before-block.txt`](../../evidence/a1-jgroups-transport-block/02-control-before-block.txt) +``` +vendor_jgroups_merge3_get_num_merge_events 0.0 (양쪽 노드) +vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 (양쪽 노드) +``` + +## 1-6. 대조군 — 차단 전에 본 시험을 한 번 그대로 돌린다 + +**이 절을 건너뛰면 뒤의 숫자는 아무 의미가 없다.** A-0 에서 배운 규칙이다. + +### 왜 임시 파드를 쓰나 + +- Keycloak 이미지에 `curl` 이 없다 → 파드 안에서는 못 친다 +- 토큰을 단계 사이로 넘겨야 한다 → **한 셸 안에서** 다 해야 한다 +- Service 로 가면 **어느 노드가 처리했는지 알 수 없다** → 파드 IP 로 직접 친다. + 이 실험의 질문 자체가 「어느 노드인가」다 + +**하기** — 임시 파드를 띄우고 그 안의 셸에 들어간다 +```bash +sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \ + --image=curlimages/curl:8.11.1 \ + --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 -- sh +``` + +**되돌리기** — 셸에서 `exit` 하면 `--rm` 이 파드를 지운다. + +> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도 +> 셸 히스토리에도 남지 않는다(히스토리에는 `$(...)` 문자열만 남는다). +> 길이만 확인하고 싶으면 밖에서: +> ```bash +> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \ +> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c +> ``` + +여기부터는 **파드 안 셸**이다. 프롬프트가 `/ $` 로 바뀐다. + +**하기 ①** — `keycloak-0` 에서 로그인한다. 이 노드가 세션의 출생지다 +```sh +TOK=/realms/master/protocol/openid-connect/token +curl -s -X POST "http://$K0:8080$TOK" \ + -d grant_type=password -d client_id=admin-cli \ + -d username=admin -d "password=$PW" +``` + +**형태** — 한 줄 JSON 이 나온다. 한 번은 통째로 본다 +```json +{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800, + "refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"} +``` + +**어디를 봐야 하는가** — `expires_in` 60, `refresh_expires_in` 1800. +**access token 은 60초짜리고 그동안은 서버에 안 물어본다.** 그래서 이 실험의 +탐침은 access token 이 아니라 **refresh** 다 — refresh 는 노드가 세션 저장소를 +실제로 뒤져야 답할 수 있다. + +**하기 ②** — 토큰을 변수에 담는다 +```sh +R=$(curl -s -X POST "http://$K0:8080$TOK" \ + -d grant_type=password -d client_id=admin-cli \ + -d username=admin -d "password=$PW") +RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') +AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') +echo "refresh=${#RT}자 access=${#AT}자" +``` +**형태** +``` +refresh=1187자 access=2043자 +``` +길이가 `0자` 로 나오면 로그인이 실패한 것이다. `echo "$R"` 로 에러 본문을 본다. + +**하기 ③** — 이 세션의 sid 를 확인한다. JWT 의 가운데 토막이 클레임이다 +**미검증** +```sh +echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo +``` +**형태** +```json +{"exp":1757037660,"iat":1757037600,"jti":"...","iss":"http://10.42.1.43:8080/realms/master", + "sub":"...","typ":"Bearer","azp":"admin-cli","sid":"tAWs2gCPr6SOcD4jDR9-_CzB",...} +``` +`"sid"` 값을 적어 둔다. **뒤에서 DB 를 직접 뒤질 때 이 값이 필요하다.** + +> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다). +> `sid` 는 앞쪽에 있어서 대개 보인다. 그래도 안 보이면 sid 없이 진행하고, +> DB 확인은 [4-6](#4-6-기제-확정--db-는-지워졌는데-캐시가-답한다) 의 개수 세기로 대신한다. + +**하기 ④** — 대조군 본 시험. **반대편 노드에서 refresh** +```sh +R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \ + -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT") +echo "$R" | tail -1 +RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') +``` +**실측** — [`02-control-before-block.txt`](../../evidence/a1-jgroups-transport-block/02-control-before-block.txt) +``` + sid tAWs2gCPr6SOcD4jDR9-_CzB + keycloak-1 에서 refresh: 200 +``` + +**이 결과가 의미하는 것** — 차단 전에는 교차 노드 refresh 가 된다. **이 200 이 +기준선이다.** 차단 후에도 200 이면 「원래 되던 게 그대로 되는 것」이고, +차단 후 400 이면 「내가 깨뜨린 것」이다. 대조군 없이는 이 구별이 안 된다. + +> **refresh token 은 회전한다.** 갱신할 때마다 새 것이 나오므로 **매번 `RT` 를 +> 다시 담는다.** 옛 것을 계속 쓰면 나중에 나오는 400 이 무효화 때문인지 재사용 +> 때문인지 알 수 없게 된다. + +**하기 ⑤** — 로그아웃 전파도 대조군을 잡는다 +```sh +curl -s -o /dev/null -w '%{http_code}\n' -X POST \ + "http://$K1:8080/realms/master/protocol/openid-connect/logout" \ + -d client_id=admin-cli -d "refresh_token=$RT" + +curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \ + -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT" +``` +**형태** — 정상 클러스터에서는 +``` +204 +{"error":"invalid_grant","error_description":"Session not active"} +400 +``` + +**이 결과가 의미하는 것** — `keycloak-1` 에서 로그아웃하면 `keycloak-0` 에서도 +갱신이 막힌다. **무효화가 전파된다.** + +> 이 400 은 A-0 에서 측정한 값이다. A-1 의 대조군 기록 +> ([`02-control-before-block.txt`](../../evidence/a1-jgroups-transport-block/02-control-before-block.txt)) +> 에는 refresh 200 만 있고 로그아웃 단계는 없다. **당신은 지금 직접 재 두는 +> 것이 낫다** — 뒤에서 이 자리가 200 으로 바뀌는 것이 이 실험의 결론이다. + +`exit` 로 파드에서 나온다. + +--- + +# 2. 주입 — NetworkPolicy 로 7800 만 막는다 + +여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.** + +**되돌리기** +```bash +sudo kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport +``` + +## 2-1. 무엇을 적용하는가 — 먼저 읽는다 + +**확인** +```bash +cat deploy/lab/k8s/a1-block-jgroups-transport.yaml +``` + +핵심은 이 부분이다. +```yaml +spec: + podSelector: { matchLabels: { app: keycloak } } + policyTypes: [Ingress] + ingress: + - ports: + - { port: 8080, protocol: TCP } # HTTP — 열어둔다 + - { port: 9000, protocol: TCP } # health+metrics — 열어둔다 + # 7800 은 일부러 없다 +``` + +### 개념 — NetworkPolicy 는 방화벽이 아니라 **허용 목록**이다 + +**「7800 을 거부」라고 쓸 방법이 없다.** 파드가 `policyTypes: [Ingress]` 를 가진 +정책에 선택되는 순간 **모든 인바운드가 거부**되고, 규칙에 적힌 것만 통과한다. +그래서 7800 은 **빠뜨림으로써** 막힌다. + +이 구조 때문에 두 허용 규칙이 **결정적**이다. 잘못 쓰면 분단된 클러스터가 아니라 +**죽은 Keycloak 을 측정하게 된다.** + +| 포트 | 빼면 | +|---|---| +| 8080 | Traefik·상대 노드의 REST 호출이 전부 끊긴다 | +| **9000** | **readiness 프로브가 실패해 kubelet 이 파드를 죽인다** — 엉뚱한 이유로 클러스터가 깨진다 | + +**덤으로 57800 도 막힌다.** FD_SOCK2(장애 감지 채널)는 `bind_port + 50000` 을 +쓴다. 손으로 「7800 거부」 규칙을 쓰면 이걸 빠뜨리기 쉽지만, 허용 목록 방식은 +8080·9000 외 전부 거부이므로 **자동으로 같이 막힌다.** + +## 2-2. 적용 + +**하기** +```bash +sudo kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml +date '+%H:%M:%S 적용' +``` +**실측** — [`03-block-applied.txt`](../../evidence/a1-jgroups-transport-block/03-block-applied.txt) +``` +networkpolicy.networking.k8s.io/a1-block-jgroups-transport created +적용 시각: 11:38:08 +``` + +**시각을 반드시 적어 둔다.** 뒤에서 Prometheus 로 「언제부터 변했나」를 볼 때 +이 시각이 없으면 인과를 못 붙인다. 실제로 이 실험은 **시각이 겹친 것을 인과로 +잘못 읽었다가 나중에 정정했다** — 해설 문서 4절의 ★ 정정. + +--- + +# 3. 주입이 실제로 걸렸는지 확인한다 + +**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.** +이 실험이 남긴 가장 큰 교훈이 여기 있다. + +## 3-1. 정책이 어떤 파드를 잡았나 + +**확인** +```bash +sudo kubectl -n keycloak-lab get networkpolicy +sudo kubectl -n keycloak-lab describe networkpolicy a1-block-jgroups-transport +``` +**형태** +``` +PodSelector: app=keycloak +Allowing ingress traffic: + To Port: 8080/TCP + To Port: 9000/TCP + From: (traffic not restricted by source) +Policy Types: Ingress +``` + +**어디를 봐야 하는가** — `To Port` 목록에 **7800 이 없는 것**. 그게 전부다. +`PodSelector` 가 `app=keycloak` 인 것도 확인한다. 오타로 아무 파드도 안 잡히면 +정책은 걸렸는데 아무 일도 안 일어난다. + +## 3-2. 엉뚱한 것을 죽이지 않았나 + +**확인** +```bash +sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak +``` +**실측** +``` +파드 상태: keycloak-0 ready=true restarts=0 + keycloak-1 ready=true restarts=0 +``` + +**어디를 봐야 하는가** — **`RESTARTS` 가 여전히 0.** + +**이 결과가 의미하는 것** — 9000 을 제대로 열어 둬서 readiness 프로브가 살아 있다. +여기서 `RESTARTS` 가 오르고 `READY` 가 `0/1` 이면 **9000 을 막은 것**이고, +그 상태에서 무엇을 재든 「분단된 클러스터」가 아니라 「죽은 파드」를 재는 것이다. +즉시 정책을 지우고 매니페스트를 다시 본다. + +## 3-3. 열어 둔 포트는 살아 있나 · 막은 포트는 죽었나 + +**하기** — 임시 파드를 다시 띄운다 +```bash +sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \ + --image=curlimages/curl:8.11.1 --env="K0=$K0" --env="K1=$K1" --command -- sh +``` + +파드 안에서: +```sh +curl -s -o /dev/null -w '9000 %{http_code}\n' --max-time 5 "http://$K0:9000/health/ready" +curl -s -o /dev/null -w '8080 %{http_code}\n' --max-time 5 "http://$K0:8080/realms/master" +curl -s -o /dev/null -w '7800 %{http_code}\n' --max-time 5 "http://$K0:7800/" ; echo "exit=$?" +``` +**실측** — [`03`](../../evidence/a1-jgroups-transport-block/03-block-applied.txt) · +[`05`](../../evidence/a1-jgroups-transport-block/05-conntrack-problem.txt) +``` +9000 도달: 10.42.1.43:9000 health=200 / 10.42.0.35:9000 health=200 +8080 도달: 10.42.1.43:8080 root=200 / 10.42.0.35:8080 root=200 +7800: curl exit=7 (연결 실패) +``` + +**어디를 봐야 하는가** — 8080·9000 은 `200`, 7800 은 **curl 종료코드**. + +| curl exit | 뜻 | +|---|---| +| `7` | 연결 자체가 안 됨 | +| `28` | `--max-time` 초과 = SYN 이 조용히 버려지고 있음 | +| `0` | **닿았다 — 정책이 안 걸린 것이다** | + +7 이든 28 이든 「안 닿는다」이고, 정책은 걸린 것이다. + +> **임시 파드는 정책에 안 잡힌다.** `podSelector` 가 `app=keycloak` 이라 +> 이 파드의 인바운드는 제한되지 않는다. 그런데도 7800 에 못 닿는 이유는 +> **정책이 목적지(Keycloak 파드)의 인바운드를 막기 때문**이다. 출발지가 +> 무엇이든 상관없다. + +`exit` 으로 나온다. + +## 3-4. ★ 그런데 클러스터가 안 깨졌다 + +**확인** +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' +``` +**실측** — [`07-cluster-size.txt`](../../evidence/a1-jgroups-transport-block/07-cluster-size.txt) +``` +=== vendor_cluster_size — 지난 25분 (차단 11:38:08) === + keycloak-0: 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2 + keycloak-1: 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2 +``` + +**신규 연결은 분명히 막히는데 클러스터는 25분 내내 2 다.** +여기서 「실험 실패」라고 결론 내리면 틀린다. 파드 안 소켓을 본다. + +**확인** — Keycloak 이미지에는 `ss` 도 없다. `/proc` 을 직접 읽는다 +```bash +sudo kubectl -n keycloak-lab exec keycloak-0 -- cat /proc/net/tcp6 | grep 1E78 +``` +**실측** +``` +=== /proc/net/tcp6 · 7800 = 0x1E78 === +keycloak-0: ...2B012A0A:1E78 ...23002A0A:9C57 01 ← 01 = ESTABLISHED +keycloak-1: ...23002A0A:9C57 ...2B012A0A:1E78 01 + (10.42.0.35:40023 → 10.42.1.43:7800) +``` + +**어디를 봐야 하는가** — 포트는 **16진수**다. `7800 = 0x1E78`. 세 번째 열 +`01` 이 TCP 상태이며 **`01` = ESTABLISHED**. + +**이 결과가 의미하는 것** — **기존 연결이 멀쩡히 살아 있다.** + +## 3-5. 왜 그런가 — conntrack + +``` + 패킷 도착 + │ + ├─▶ [ conntrack: ESTABLISHED/RELATED 이면 ACCEPT ] ← 여기서 통과해버린다 + │ + └─▶ [ NetworkPolicy 규칙 평가 ] ← 여기까지 오지 않는다 +``` + +리눅스 방화벽은 성능을 위해 **이미 성립한 연결을 먼저 통과**시킨다. +NetworkPolicy 는 그 뒤에 있으므로 **신규 연결(SYN)만** 걸러낸다. + +**확인** — 두 노드 모두에서 본다 +```bash +sudo conntrack -L 2>/dev/null | grep 7800 +ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800' +``` +**실측** — [`05-conntrack-problem.txt`](../../evidence/a1-jgroups-transport-block/05-conntrack-problem.txt) +``` +--- kc-lab-1 --- + tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1 + tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 src=10.42.1.43 dst=10.42.0.35 sport=57800 dport=50477 [ASSURED] mark=0 use=1 +--- kc-lab-2 --- + tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 ... + tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] ... +``` + +> `2>/dev/null` 은 `conntrack` 이 stderr 로 찍는 「N flow entries have been shown」 +> 요약을 지우려는 것이다. 처음에는 빼고 쳐서 그 줄도 한번 본다. + +**어디를 봐야 하는가** — 상태 열. + +| 상태 | 뜻 | +|---|---| +| `ESTABLISHED` | **양방향 통신 성립 — 규칙 평가를 건너뛴다** | +| `[ASSURED]` | 충분히 오래된 연결. 표가 꽉 차도 안 지워진다 | +| `SYN_SENT [UNREPLIED]` | 보냈는데 답이 없음 = **정책이 동작하고 있다는 증거** | + +**`dport=57800` 도 있다.** FD_SOCK2 채널이며, 이것도 ESTABLISHED 로 살아 있다. + +> **운영적 함의 — NetworkPolicy 는 이미 붙어 있는 것을 떼어내지 못한다.** +> 보안 사고 대응으로 「지금 당장 이 통신을 끊어라」에 NetworkPolicy 를 걸면 +> **새 연결만 막히고 진행 중인 연결은 계속된다.** 끊으려면 conntrack 을 지우거나 +> 파드를 재시작해야 한다. + +## 3-6. conntrack 항목을 지운다 + +위 출력의 **값을 그대로** 넣는다. 튜플이 정확해야 지워진다. + +**하기** — `kc-lab-1` 에서 +```bash +sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 40023 --dport 7800 +sudo conntrack -D -p tcp -s 10.42.1.43 -d 10.42.0.35 --sport 7800 --dport 40023 +sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 50477 --dport 57800 +``` +`kc-lab-2` 에서도 같은 일을 한다. **서버 쪽 노드에는 튜플이 뒤집혀 기록되어 있다.** + +**형태** +``` +conntrack v1.4.7 (conntrack-tools): 1 flow entries have been deleted. +``` + +**어디를 봐야 하는가** — 삭제 건수. `0 flow entries have been deleted` 면 +**튜플이 틀린 것**이다. `--dport 7800` 만 주면 0 건이 나온다 — 실제로 원래 +실행에서 그렇게 나왔다. + +**확인** — 다시 세어 본다 +```bash +sudo conntrack -L 2>/dev/null | grep -c 7800 +ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep -c 7800' +``` + +### ★ 여기서 정직하게 알아 둘 것 + +원래 실행에서 **conntrack 을 지운 뒤에도 `vendor_cluster_size` 는 계속 2 였다.** +해설 문서는 처음에 「conntrack 삭제 → 3분 뒤 분단」이라고 썼다가 증거를 다시 보고 +정정했다. 실제 하락은 **파드가 재시작된 4초 뒤**에 일어났다. + +**즉, 이 절만으로 분단이 만들어지는지는 이 실험이 판정하지 못했다.** +확실하게 분단을 만드는 방법은 다음 절이다. + +## 3-7. 정책이 걸린 채 파드를 재시작한다 — 이게 분단을 만든다 + +**하기** +```bash +date '+%H:%M:%S 재시작' +sudo kubectl -n keycloak-lab delete pod keycloak-0 +``` +**실측** — [`08-restart-forced-partition.txt`](../../evidence/a1-jgroups-transport-block/08-restart-forced-partition.txt) +``` +재시작 시각: 11:46:07 +pod "keycloak-0" deleted from keycloak-lab namespace +keycloak-0 false 10.42.1.67 2026-09-04T02:44:23Z +``` + +**되돌리기** — StatefulSet 이 같은 이름으로 곧바로 다시 만든다. 별도 조치 없음. + +> 정책이 걸린 채 파드가 **스스로** 재시작하는 일도 있다. 원래 실행에서 실제로 +> 그랬다(`startTime 11:44:23`). `RESTARTS` 나 `startTime` 이 이미 바뀌어 있으면 +> `delete` 를 칠 필요도 없다. + +**확인** — 새 파드가 뜨고 IP 가 바뀐 것을 본다 +```bash +sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak +K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') +echo "$K0" +``` +**실측** +``` +10.42.1.67 ← 10.42.1.43 에서 바뀌었다 +``` + +**★ IP 가 바뀌었다는 것을 반드시 반영한다.** 아까 띄운 임시 파드의 `K0` 환경변수는 +낡았다. 뒤에서 파드를 다시 띄울 때 새 IP 로 띄운다. 이걸 놓치면 「아무 데도 안 닿음」을 +「분단」으로 착각한다. + +--- + +# 4. 효과를 관찰한다 + +## 4-1. 클러스터 크기가 떨어졌나 + +**확인** — 2~4분에 걸쳐 몇 번 친다 +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' +``` +**실측** +``` + keycloak-0: 11:45:27=1 11:45:57=1 11:46:27=1 11:46:57=1 11:47:27=1 + keycloak-1: ... 11:43:57=2 11:44:27=1 11:44:57=1 ... 11:47:27=1 +``` + +**어디를 봐야 하는가** — **양쪽 다 1.** 서로를 멤버로 안 세고 있다. + +## 4-2. 로그가 이유를 말한다 + +**확인** +```bash +sudo kubectl -n keycloak-lab logs keycloak-0 | grep -E "GMS|ISPN000094" | tail -20 +``` +**실측** +``` +GMS: JOIN(keycloak-0-26403) sent to keycloak-1-48749 timed out ← 10회 +GMS: too many JOIN attempts (10): becoming singleton ← 포기 +ISPN000094: new cluster view [keycloak-0-26403|0] (1) [keycloak-0-26403] +``` +```bash +sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1 +``` +**실측** +``` +ISPN000094: [keycloak-1-48749|6] (1) [keycloak-1-48749] +``` + +**이 결과가 의미하는 것** — 새로 뜬 `keycloak-0` 은 DB 에서 `keycloak-1` 을 +**찾았다.** 주소도 안다. 그런데 **JOIN 메시지가 7800 으로 안 간다.** 10번 시도하고 +포기해서 혼자가 됐다. 디스커버리는 살아 있고 트랜스포트만 죽은 상태다. + +## 4-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" +``` +**실측** — [`06`](../../evidence/a1-jgroups-transport-block/06-partition-observed.txt) +``` + name | ip | coord +------------------+-----------------+------- + keycloak-0-26403 | 10.42.1.67:7800 | t ← 코디네이터 + keycloak-1-48749 | 10.42.0.35:7800 | t ← 코디네이터 +``` + +**어디를 봐야 하는가** — **`coord = t` 가 둘.** + +**이 결과가 의미하는 것** — 교과서적인 split brain 이다. 서로를 못 보니까 각자 +자기가 대장이라고 생각한다. **기준선(1-4)에서 `t` 가 하나였던 것과 대조한다.** +분단을 확인하는 가장 짧은 명령이 이것이다. + +## 4-4. 분단된 노드가 스스로 빠진다 — 예상 못 한 발견 + +**확인** +```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 +``` +**실측** — [`11-service-impact.txt`](../../evidence/a1-jgroups-transport-block/11-service-impact.txt) +``` +keycloak-0 false 0 +keycloak-1 true 0 +``` + +**왜 `keycloak-0` 만 false 인가** — 물어본다. +```bash +sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A5 Conditions +``` +**실측** — 헬스 응답 원문 +```json +{ "status": "DOWN", + "checks": [ + { "name": "Keycloak cluster health check", "status": "DOWN", + "data": { "Failing since": "2026-09-04 02:45:14,251" } }, + { "name": "Keycloak database connections async health check", "status": "UP" } ] } +``` + +**Keycloak 은 클러스터 분단을 readiness 로 신고한다.** DB 는 UP 인데 클러스터가 +DOWN 이다. 그리고 쿠버네티스가 그 신고를 받아 처리한다. + +**확인** — 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 +``` +**실측** +``` + ready 주소: [10.42.0.35] ← keycloak-1 만 트래픽을 받는다 + notReady : [10.42.1.67] ← keycloak-0 은 제외되었다 +``` + +> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가 뜬다. +> `endpointslice` 를 본다. + +**확인** — 밖에서는 멀쩡한가 +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master +``` +**실측** +``` + https://auth.hyeonworks.com/realms/master HTTP 200 + 토큰 발급 HTTP 200 +``` + +**이 결과가 의미하는 것** — **분단된 노드가 스스로 로드밸런서에서 빠졌고, 서비스는 +계속됐다.** liveness 였다면 재시작을 반복했을 텐데, 재시작해도 안 나아지는 문제이므로 +**readiness(격리)가 맞는 신호**다. + +> **다만 비대칭이라서 살았다.** `keycloak-1` 은 「멤버가 하나 줄어든」 정상적인 +> 사건이라 Ready 를 유지했고, `keycloak-0` 은 **합류 자체를 못 해** DOWN 이 됐다. +> 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애다 — A-5 의 주제. + +## 4-5. 본 시험 — 분단 상태에서 세션은 어떻게 되는가 + +**Service 를 쓰면 안 된다.** `keycloak-0` 이 NotReady 라 Service 로 보내면 전부 +`keycloak-1` 로 간다. **파드 IP 로 직접** 친다. + +**하기** — 새 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}') +sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \ + --image=curlimages/curl:8.11.1 \ + --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 -- sh +``` + +파드 안에서 **1-6 과 똑같은 순서**를 다시 한다. + +```sh +TOK=/realms/master/protocol/openid-connect/token + +# [1] keycloak-0 에서 로그인 +R=$(curl -s -X POST "http://$K0:8080$TOK" \ + -d grant_type=password -d client_id=admin-cli \ + -d username=admin -d "password=$PW") +RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') +AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') +echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo # sid 를 적어 둔다 + +# [2] keycloak-1 에서 refresh +R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \ + -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT") +echo "$R" | tail -1 +RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') + +# [3] keycloak-1 에서 로그아웃 +curl -s -o /dev/null -w '%{http_code}\n' -X POST \ + "http://$K1:8080/realms/master/protocol/openid-connect/logout" \ + -d client_id=admin-cli -d "refresh_token=$RT" + +# [4] keycloak-0 에서 재갱신 시도 +curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \ + -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT" +``` + +**실측** — [`09-cross-node-under-partition.txt`](../../evidence/a1-jgroups-transport-block/09-cross-node-under-partition.txt) +``` + [1] keycloak-0 로그인 sid=nShl5TaBrZnKStDqaspjgmJB + [2] keycloak-1 에서 refresh HTTP 200 ← 예측대로 + [3] keycloak-1 에서 로그아웃 HTTP 204 + [4] keycloak-0 에서 재갱신 시도 HTTP 200 ← 400 이어야 했다 +``` + +**[2] 세션 공유 — 예측이 맞았다.** 클러스터가 갈라졌는데도 한쪽에서 만든 세션을 +반대쪽이 갱신했다. **세션은 7800 으로 다니지 않는다.** 통념이 틀렸다. + +**[4] 로그아웃 전파 — 예측이 틀렸다.** 대조군(1-6)에서 400 이던 자리가 200 이다. +**로그아웃한 세션이 반대편에서 살아 있다.** + +`exit` 으로 나온다. + +## 4-6. 기제 확정 — DB 는 지워졌는데 캐시가 답한다 + +**[4] 의 200 이 「로그아웃이 아예 안 됐다」는 뜻인지 확인해야 한다.** DB 를 본다. +sid 는 위 [1] 에서 적어 둔 값이다. + +**확인** +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "select user_session_id, offline_flag, last_session_refresh + from offline_user_session where user_session_id='nShl5TaBrZnKStDqaspjgmJB'" +``` +**실측** — [`10-logout-not-propagated.txt`](../../evidence/a1-jgroups-transport-block/10-logout-not-propagated.txt) +``` + user_session_id | offline_flag | last_session_refresh +-----------------+--------------+---------------------- +(0 rows) ← DB 행은 삭제되었다 +``` + +> sid 를 못 뽑았다면 개수로 본다. 로그인 전후·로그아웃 전후로 세 번 친다. +> ```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 호출도 세션을 만들기 때문에 **개수는 노이즈가 있다.** sid 로 보는 편이 정확하다. + +**확인** — 노드별 세션 캐시 엔트리 +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique{cache="sessions"}' +``` +**실측** +``` + keycloak-1 kc-lab-1 = 0 + keycloak-0 kc-lab-2 = 1 ← 캐시에는 남아 있다 +``` + +> 중괄호 때문에 `wget` 이 싫어하면 라벨을 빼고 걸러 낸다. **미검증** +> ```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]' +> ``` + +**이 결과가 의미하는 것** + +``` + keycloak-1 로그아웃 + │ + ├──▶ PostgreSQL 행 삭제 ✔ 되었다 + │ + └──▶ keycloak-0 에게 "캐시에서 지워라" ✗ 7800 이 막혀 못 갔다 + │ + keycloak-0 은 자기 캐시로 200 을 준다 ◀────────────┘ +``` + +**룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다.** 캐시에 있으면 그걸로 답한다. +캐시 무효화는 **클러스터 메시지(7800)를 타고** 간다. + +| | 세션 **조회** | 세션 **무효화** | +|---|---|---| +| 경로 | PostgreSQL | **클러스터 메시지 (7800)** | +| 7800 차단 시 | 정상 | **전파되지 않음** | + +> **그럼 실제 사용자도 로그아웃이 안 되나?** 아니다. 당신은 Service 를 우회해 +> **파드 IP 로 직접** 쳤다. 실제 사용자는 nginx → Traefik → Service 를 거치고, +> **NotReady 인 `keycloak-0` 은 거기서 빠져 있다.** 정문으로 들어오면 낡은 캐시에 +> 닿지 않는다. 4-4 의 readiness 게이트가 막는다. + +--- + +# 5. 복구 + +## 5-1. 정책을 지운다 + +**하기** +```bash +date '+%H:%M:%S 해제' +sudo kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport +``` +**실측** — [`12-recovery.txt`](../../evidence/a1-jgroups-transport-block/12-recovery.txt) +``` +해제 시각: 11:49:58 +networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace +``` + +## 5-2. 자동으로 다시 붙는지 본다 + +**확인** — 30초 간격으로 몇 번 친다 +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' +``` +**실측** +``` + +30초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개 + +60초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개 + +90초 keycloak-0=2 keycloak-1=2 ← 재형성 +``` + +**90초 만에 자동으로 다시 붙었다. 사람 손이 필요 없었다.** +2~3분 기다려도 1 이면 [막히면](#막히면) 표를 본다. + +## 5-3. 누가 붙였나 — MERGE3 + +**확인** +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events' +``` +**실측** +``` + merge_events keycloak-0 = 1 + merge_events keycloak-1 = 1 +``` + +**어디를 봐야 하는가** — 기준선(1-5)에서 `0.0` 이던 값이 **1** 이다. + +**이 결과가 의미하는 것** — MERGE3 는 split brain 을 감지해 갈라진 뷰를 병합하는 +JGroups 프로토콜이다. 주기적으로 다른 코디네이터의 존재를 확인하고, 발견하면 +병합을 개시한다. **지표가 `0 → 1` 로 오른 것이 「MERGE3 가 실제로 일했다」는 증거다.** + +## 5-4. 코디네이터가 하나로 돌아왔나 + +**확인** +```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" +``` +**실측** +``` + keycloak-0-26403 | 10.42.1.67:7800 | t + keycloak-1-48749 | 10.42.0.35:7800 | f ← 코디네이터가 하나로 돌아왔다 +``` + +**코디네이터가 `keycloak-1` 에서 `keycloak-0` 으로 넘어갔다.** 코디네이터는 +특권이 아니라 **역할**이며 병합 시 재선출된다. 기준선과 달라도 정상이다. + +## 5-5. 원상복구 확인표 + +| 항목 | 명령 | 돌아왔을 때 | +|---|---|---| +| 정책 | `sudo kubectl -n keycloak-lab get networkpolicy` | `No resources found` | +| 파드 | `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)`, 양쪽 동일 | +| 디스커버리 | 위 5-4 | `coord = t` 가 **하나** | +| 지표 | `vendor_cluster_size` | 양쪽 `2` | +| 임시 파드 | `sudo kubectl -n keycloak-lab get pod kc-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 kc-probe --ignore-not-found +``` + +conntrack 은 지운 채로 두면 된다. **표는 새 패킷이 오면 다시 채워진다.** + +> **이 실험이 재지 않은 것** — 4-6 에서 `keycloak-0` 캐시에 남아 있던 낡은 엔트리가 +> 병합 후 어떻게 되는지는 측정하지 않았다. 궁금하면 5-2 뒤에 +> `vendor_statistics_approximate_entries_unique{cache="sessions"}` 를 다시 본다. + +--- + +# 막히면 + +전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다. + +| 증상 | 원인 | 확인 | +|---|---|---| +| 정책을 걸었는데 지표가 안 변한다 | conntrack 의 ESTABLISHED 가 먼저 통과시킨다 | `sudo conntrack -L 2>/dev/null \| grep 7800` — 3-5 | +| `conntrack -D` 가 `0 flow entries` | 튜플이 틀렸다. `--dport` 만으로는 0건 | `-L` 출력의 src/dst/sport/dport 를 **그대로** 옮긴다 | +| conntrack 을 지웠는데도 계속 2 | **이 실험은 그것만으로 분단되는지 판정 못 했다** | 정책이 걸린 채 파드를 재시작한다 — 3-7 | +| 파드가 재시작을 반복한다 (`RESTARTS` 증가) | **9000 을 안 열었다.** readiness 실패 → kubelet 이 죽인다 | `describe pod` 의 Events. 매니페스트에 9000 이 있는지 | +| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 임시 curl 파드를 띄우거나 Prometheus 에 묻는다 | +| refresh 가 계속 `keycloak-1` 로만 간다 | Service 로 보냈다. NotReady 파드는 빠진다 | **파드 IP 로 직접** — 4-5 | +| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** (`10.42.1.43 → 10.42.1.67`) | `get pod -o jsonpath='{.status.podIP}'` 다시 | +| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` | +| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | 파드 안에서 `echo ${#PW}` — 0 이면 `--env` 가 빈 값 | +| 값이 빈 문자열인데 「변했다」로 읽힌다 | **원래 실행이 이 실수를 했다** ([`03`](../../evidence/a1-jgroups-transport-block/03-block-applied.txt)) | 빈 값은 「측정 실패」다. 판정 조건에서 빼고 다시 잰다 | +| 복구 후 2~3분이 지나도 1 | MERGE3 주기 밖이거나 정책이 안 지워졌다 | `get networkpolicy` 로 먼저 확인 | + +--- + +# 왜 이 가이드에는 스크립트가 없나 + +원래 실행은 임시 파드를 20초마다 띄워 지표를 긁는 스크립트로 관찰했다. +그 결과가 이렇다. — [`06-partition-observed.txt`](../../evidence/a1-jgroups-transport-block/06-partition-observed.txt) + +``` + +20초 suspected(k0 k1) = [] + +60초 suspected(k0 k1) = [0.0 0.0 0.0 0.0 ] + +140초 suspected(k0 k1) = [0.0 ] +``` + +**빈 값과 개수가 안 맞는 값이 섞였다.** `kubectl run --rm` 은 매번 파드를 만들고 +지우므로 느리고 경합이 있다. 게다가 판정 조건이 `[ "$R" != "0.0 0.0 " ]` 이어서 +**빈 문자열을 「변화」로 읽고 즉시 빠져나왔다.** + +> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가 +> 그러라고 있는 것이다. + +그래서 이 가이드는 관찰을 **한 번에 하나씩 손으로** 친다. 값이 이상하면 그 자리에서 +보이고, 다시 칠 수 있고, 무엇을 봤는지 남는다. + +--- + +# 다음 + +| 실험 | A-1 이 남긴 질문 | +|---|---| +| [A-5](../../experiment-a5-asymmetric-partition.md) 비대칭 파티션 | **양쪽이 동시에 NotReady 가 되는 경로가 있는가.** 여기서는 비대칭이라 살았다 | +| [A-2](../../experiment-a2-database-loss.md) DB 정지 | 캐시가 DB 와 대조하지 않으므로 **캐시에 있는 세션은 DB 없이도 읽힐 수 있다** | +| [A-7](../../experiment-a7-volatile-comparison.md) volatile 비교 | 같은 주입에서 세션 공유가 **깨져야** 한다. A-1 이 그 대조군 | +| 전부 | **주입이 실제로 걸렸는지 먼저 확인한다.** NetworkPolicy 는 기존 연결을 못 끊는다 | diff --git a/docs/guides/experiments/a2-database-loss.md b/docs/guides/experiments/a2-database-loss.md new file mode 100644 index 0000000..f94bc0c --- /dev/null +++ b/docs/guides/experiments/a2-database-loss.md @@ -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 와 외부 응답 코드에 건다 | diff --git a/docs/guides/experiments/a3-database-crash.md b/docs/guides/experiments/a3-database-crash.md new file mode 100644 index 0000000..9cc8285 --- /dev/null +++ b/docs/guides/experiments/a3-database-crash.md @@ -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 로그 | diff --git a/docs/guides/experiments/a4-node-loss.md b/docs/guides/experiments/a4-node-loss.md new file mode 100644 index 0000000..a7b727c --- /dev/null +++ b/docs/guides/experiments/a4-node-loss.md @@ -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 + +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 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 +keycloak-0 Running true kc-lab-2 +keycloak-1 Running false kc-lab-1 +postgres-7b474b88c8-2gf27 Running true kc-lab-2 +``` + +**어디를 봐야 하는가** — `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 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 ( ago) 151m +postgres-7b474b88c8-9cmsv 1/1 Running 0 11m +``` + +**어디를 봐야 하는가** — 세 가지가 한 줄에 있다. + +- `keycloak-1` 의 `RESTARTS` 가 **1** — `kc-lab-1` 위에 있었으니 당연하다 +- `AGE` 가 `151m` 인데 재시작은 방금 — **AGE 는 파드가 만들어진 시각**이지 + 컨테이너가 시작한 시각이 아니다 +- **`( 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 ( 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` 이 못 잡는다** | +| 전부 | **주입 검증의 기준을 먼저 정한다.** 여기서는 쿠버네티스가 아니라 하이퍼바이저가 기준이었다 | diff --git a/docs/guides/experiments/a5-asymmetric-partition.md b/docs/guides/experiments/a5-asymmetric-partition.md new file mode 100644 index 0000000..4677ff7 --- /dev/null +++ b/docs/guides/experiments/a5-asymmetric-partition.md @@ -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 "", line 3, in +> 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) 로 되돌아가서 | 분단 중 **로그아웃이 전파되지 않는다.** 그 상태를 여기서 다시 만들 수 있다 | +| 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 | diff --git a/docs/guides/experiments/a6-latency-injection.md b/docs/guides/experiments/a6-latency-injection.md new file mode 100644 index 0000000..6fb83ee --- /dev/null +++ b/docs/guides/experiments/a6-latency-injection.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 +cni0 UP 5a:77:1a:e2:b0:a4 +``` +게스트의 물리 인터페이스는 `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` 인 채로 무너진다** | +| 관측 보완 | **응답 시간 히스토그램**이 없다 | +| 전부 | **주입이 걸렸는지 카운터로 먼저 확인한다.** 세 실험 연속으로 같은 교훈 | diff --git a/docs/guides/experiments/a7-volatile-comparison.md b/docs/guides/experiments/a7-volatile-comparison.md new file mode 100644 index 0000000..46d4b01 --- /dev/null +++ b/docs/guides/experiments/a7-volatile-comparison.md @@ -0,0 +1,1072 @@ +# A-7 재현 가이드 — 옛 방식으로 바꿔서 A층 결론이 뒤집히는 것을 직접 본다 + +해설 문서: [`docs/experiment-a7-volatile-comparison.md`](../../experiment-a7-volatile-comparison.md) · +증거 원문: [`docs/evidence/a7-volatile-comparison/`](../../evidence/a7-volatile-comparison/) + +## 이 가이드가 끝나면 + +당신 터미널에서 이것들을 **직접 본다.** + +| 보게 되는 것 | 어디서 | +|---|---| +| 로그인했는데 DB 세션 테이블이 **0건**인 상태 | PostgreSQL `OFFLINE_USER_SESSION` | +| 그런데도 교차 노드 refresh 가 `200` 인 것 | 탐침 파드 | +| 롤링 재시작 한 번에 **전원 로그아웃**되는 것 | 재시작 전 토큰으로 refresh → `400` | +| 7800 을 끊으면 **이번에는 세션 공유가 깨지는 것** | `iptables -t raw` · 교차 노드 `400` | +| DB 를 내렸는데 **새 로그인이 되는 것** | `scale deployment/postgres --replicas=0` | +| 같은 명령이 A-1·A-8 과 정반대 답을 내는 것 | 위 넷 전부 | + +## 전제 + +- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다. +- **[A-1](a1-jgroups-transport-block.md) · [A-2](a2-database-loss.md) · + [A-8](a8-rolling-restart.md) 을 먼저 해 두면 좋다.** 이 실험은 그 셋의 + **대조군**이고, 기준선을 몸으로 알고 있어야 「뒤집혔다」가 보인다. +- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다. +- `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. 4-3 의 iptables 는 **두 노드에 각각** + 넣는다. +- 터미널 **두 개**를 열어 두면 편하다. 하나는 관찰용, 하나는 대기용. + +## 주의 — 이건 클러스터의 동작 모드를 바꾸는 실험이다 + +`persistent-user-sessions` 를 끈다. **전환하는 순간 기존 세션이 전부 사라지고**, +되돌릴 때 또 한 번 사라진다. 빌드 옵션이라 기동 시 재빌드가 일어나 롤아웃이 +평소보다 오래 걸린다(`--timeout=500s` 를 주는 이유다). + +**실험대에서만 한다.** 전 구간 약 40~60분이고, 되돌리는 방법은 매 단계에 적어 +두었다. 중간에 그만두려면 [5. 복구](#5-복구) 의 5-1 · 5-3 두 개면 된다. + +> **★ 원복을 잊으면 이후 실험이 전부 오염된다.** A-0 부터 A-6 까지의 결론은 +> 전부 「persistent 기본값」 조건이다. volatile 로 둔 채 다른 실험을 하면 +> 그 실험이 무엇을 재고 있는지 아무도 모른다. + +## 표시 규약 + +| 표시 | 뜻 | +|---|---| +| **실측** | 2026-09-04 13:22–13:32 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 | +| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 | +| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 | + +IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지 +않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의 +실제 값이다. + +--- + +# 0. 왜 이 실험을 하는가 + +A층은 여섯 개의 결론을 냈다. 그 여섯 개가 전부 **하나의 전제 위에** 있다. + +``` + Keycloak 26 은 persistent-user-sessions 가 기본으로 켜져 있다 + │ + ├─ A-0 세션은 PostgreSQL 에 있다 + ├─ A-1 7800 을 끊어도 세션 공유가 안 깨진다 + ├─ A-2 DB 를 내리면 로그인이 실패한다 + └─ A-8 롤링 재시작을 해도 세션이 산다 +``` + +**전제를 뒤집으면 결론도 뒤집히는가.** 그것이 이 실험이다. + +| | A-1 이 본 것 | 인터넷 자료가 말하는 것 | +|---|---|---| +| 7800 차단 | 세션 공유가 **안 깨진다** | 세션 공유가 **깨진다** | + +A-1 은 통념과 어긋난 결과를 냈고, 그 이유를 「26 이 기본값을 바꿨기 때문」이라고 +설명했다. **그 설명이 맞는지는 옛 기본값으로 되돌려 같은 실험을 다시 해 봐야 +판정된다.** 자료가 틀린 게 아니라 버전이 다른 것이라면, 옛 설정에서는 통념이 +맞아야 한다. + +``` + persistent (KC 25+, 26 기본) volatile (KC 24 이전) + 로그인 ─▶ PostgreSQL (진실) 로그인 ─▶ Infinispan (진실) + 조회 ─▶ 캐시 없으면 DB 조회 ─▶ 클러스터에서 찾는다 + 공유 ─▶ 같은 DB 를 본다 공유 ─▶ 7800 을 통한 복제 +``` + +**설정 한 줄로 왼쪽에서 오른쪽으로 간다.** 그 한 줄이 무엇을 바꾸는지 네 번 +측정한다. + +--- + +# 1. 기준선 — 전환하기 전에 지금이 persistent 인 것을 확인한다 + +**시험군만 재는 측정은 측정이 아니다.** 전환 후에 볼 것을 전환 전에 **똑같은 +명령으로** 먼저 봐 둔다. + +넓은 것부터 좁혀 간다. + +``` +노드 → 파드 → 지금 args → DB 세션 행 → 대조군 시험 → 이 버전에서 끌 수 있는가 +``` + +## 1-1. 노드와 파드 + +**확인** +```bash +sudo kubectl get nodes +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` +- **`NODE` 가 서로 다르다** — 같은 노드면 4-3 의 노드 간 차단이 성립하지 않는다 +- **파드 번호와 노드 번호가 어긋난다.** `keycloak-0` 이 `kc-lab-2` 에 있다. + 4-3 에서 iptables 를 어느 노드에 넣을지 정할 때 이걸 헷갈리면 규칙은 걸리는데 + 아무 일도 안 일어난다 + +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-a0-rerun.txt`](../../evidence/a7-volatile-comparison/02-a0-rerun.txt) +``` +10.42.1.94 10.42.0.45 +``` + +## 1-2. 지금 args 가 무엇인가 — **이것이 되돌릴 값이다** + +**확인** +```bash +sudo kubectl -n keycloak-lab get statefulset keycloak \ + -o jsonpath='{.spec.template.spec.containers[0].args}' ; echo +``` +**실측** — [`06-restore-persistent.txt`](../../evidence/a7-volatile-comparison/06-restore-persistent.txt) +``` +["start"] +``` + +**어디를 봐야 하는가** — `["start"]` 하나뿐이다. 플래그가 없다. + +**이 결과가 의미하는 것** — 기능 플래그를 아무것도 주지 않았으므로 **26 의 +기본값**으로 돌고 있다. `persistent-user-sessions` 가 켜져 있는 상태다. +**이 문자열을 적어 둔다.** 5-3 에서 이 값 그대로 되돌린다. + +## 1-3. DB 에 세션 행이 있다 — persistent 의 증거 + +**확인** +```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'` 이 온라인 세션**이다. +`'1'` 은 offline token 이고 이 실험과 무관하다. + +**이 결과가 의미하는 것** — 로그인한 세션이 DB 테이블에 행으로 있다. +**전환 후 이 자리가 `(0 rows)` 가 되는 것이 이 실험의 첫 판정이다.** + +> 숫자는 당신 환경에서 다르다. 관리 API 호출도 세션을 만들기 때문에 **개수에는 +> 노이즈가 있다.** 여기서 중요한 것은 **0 이 아니라는 것**뿐이다. + +## 1-4. 대조군 — 교차 노드 refresh 가 지금은 되는 것 + +**이 절을 건너뛰면 뒤의 400 이 아무 의미가 없다.** + +Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 탐침 파드를 띄운다. +**이 파드는 실험 내내 살려 둔다** — 롤링 재시작을 넘어 토큰을 들고 있어야 하기 +때문이다. + +**하기** +```bash +sudo kubectl -n keycloak-lab run a7-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/a7-probe --timeout=120s +``` + +**되돌리기** +```bash +sudo kubectl -n keycloak-lab delete pod a7-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 a7-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` 가 빈 값을 받은 것이다. 파드를 지우고 다시 띄운다. + +**하기** — `keycloak-0` 에서 로그인한다. 응답을 **한 번은 통째로 본다** +```bash +sudo kubectl -n keycloak-lab exec a7-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"' +``` +**형태** +```json +{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800, + "refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"} +``` + +**어디를 봐야 하는가** — `expires_in` 이 60 이다. **access token 은 60초짜리고 +그동안은 서버에 안 물어본다.** 그래서 이 실험의 탐침은 access token 이 아니라 +**refresh** 다 — refresh 는 노드가 세션 저장소를 실제로 뒤져야 답할 수 있다. + +**하기** — 토큰을 파드 안 파일에 담고, 반대 노드에서 갱신한다 +```bash +sudo kubectl -n keycloak-lab exec a7-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"' +``` +**형태** +``` +rt 1188 bytes +``` + +**★ 길이가 `1 bytes` 면 빈 문자열에 개행만 들어간 것이다.** 파싱이 실패했거나 +로그인이 실패한 것이다. `cat /tmp/tok` 으로 본문을 본다. 이걸 놓치고 진행하면 +**빈 토큰을 보내고 그 응답을 「세션이 죽었다」로 읽게 된다.** + +**확인** — 반대 노드에서 refresh +```bash +sudo kubectl -n keycloak-lab exec a7-probe -- sh -c \ + 'curl -s -o /dev/null -w "%{http_code}\n" -X POST \ + "http://$K1:8080/realms/master/protocol/openid-connect/token" \ + -d grant_type=refresh_token -d client_id=admin-cli \ + -d "refresh_token=$(cat /tmp/rt)"' +``` +**실측** — [`02-a0-rerun.txt`](../../evidence/a7-volatile-comparison/02-a0-rerun.txt) +``` + keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200 +``` + +**이 결과가 의미하는 것** — 지금은 교차 노드가 된다. **이 200 이 기준선이다.** + +> **refresh token 은 회전한다.** 갱신할 때마다 새 것이 나오므로 이어서 또 쓰려면 +> `/tmp/rt` 를 다시 채워야 한다. 이 가이드는 각 시험마다 **새로 로그인**해서 +> 그 문제를 피한다. + +## 1-5. 이 버전에서 정말 끌 수 있나 + +**확인** +```bash +sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \ + | tr ',' '\n' | grep -i persistent +``` +**실측** — [`01-switch-to-volatile.txt`](../../evidence/a7-volatile-comparison/01-switch-to-volatile.txt) 의 전환이 성립한 근거 +``` + persistent-user-sessions[:v1] ← 목록에 있다 +``` + +**어디를 봐야 하는가** — 이름이 목록에 있는 것. + +**이 결과가 의미하는 것** — 이 버전(`quay.io/keycloak/keycloak:26.7.0`)에서는 +아직 끌 수 있다. **목록에 없으면 그 버전에서는 이 실험을 할 수 없다** — 기능이 +제거되어 기본 동작으로 고정된 것이고, 그 자체가 답이다. + +> `--help-all` 은 출력이 길다. `tr ',' '\n'` 은 한 줄에 쉼표로 이어 붙은 기능 +> 목록을 줄로 쪼개려는 것이다. 처음 한 번은 `grep` 없이 쳐서 **어떤 기능들이 +> 있는지 통째로 본다.** + +--- + +# 2. 주입 — volatile 로 전환한다 + +여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.** + +**되돌리기** — 5-3 과 같은 명령이다 +```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 +``` + +## 2-1. 먼저 세션을 비운다 — 비교 기준을 맞추기 위해 + +**하기** +```bash +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "delete from offline_user_session" +``` +**실측** — [`01-switch-to-volatile.txt`](../../evidence/a7-volatile-comparison/01-switch-to-volatile.txt) +``` +DELETE 151 +``` + +**되돌리기** — **없다.** 지운 세션은 돌아오지 않는다. + +**왜 지우나** — 전환 후 「DB 가 0건」을 확인할 텐데, **테이블에 옛 행이 남아 +있으면 0건이 될 수 없다.** volatile 은 새로 쓰지 않을 뿐 옛 행을 지우지도 않는다. +이 한 줄을 빼먹으면 3-2 에서 「전환이 안 됐다」고 잘못 읽는다. + +> **이건 실험대라서 하는 일이다.** 운영에서 이 명령은 전원 로그아웃이다. +> 어차피 전환 자체가 세션을 날리므로 순서만 앞당기는 것이지만, **명령 자체가 +> 파괴적이라는 것은 알고 친다.** + +## 2-2. args 를 바꾼다 + +두 가지 방법이 있다. **매니페스트를 고치는 쪽을 권한다** — 무엇이 바뀌었는지 +파일에 남는다. + +**하기 ①** — 매니페스트 편집 +```bash +vim deploy/lab/k8s/keycloak-cluster.yaml +``` +```yaml +# 149번째 줄 근처 +args: ["start", "--features-disabled=persistent-user-sessions"] +``` +```bash +sudo kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml +``` + +**하기 ②** — 파일을 안 건드리고 싶으면 patch +```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"]}]' +``` + +**하기** — 롤아웃이 끝날 때까지 기다린다 +```bash +date '+%H:%M:%S 전환' +sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s +``` +**실측** — [`01-switch-to-volatile.txt`](../../evidence/a7-volatile-comparison/01-switch-to-volatile.txt) +``` +statefulset.apps/keycloak configured +Waiting for 1 pods to be ready... +partitioned roll out complete: 2 new pods have been updated... +``` + +**어디를 봐야 하는가** — `configured` 가 나와야 한다. `unchanged` 면 **args 가 +안 바뀐 것**이다. + +**이 결과가 의미하는 것** — **`--features-disabled` 는 빌드 옵션이다.** 기동 시 +재빌드가 일어나 평소보다 오래 걸린다. `--timeout=500s` 를 주는 이유가 이것이고, +`--timeout=60s` 로 주면 멀쩡한 롤아웃을 실패로 읽는다. + +**시각을 반드시 적어 둔다.** 뒤에서 지표가 「언제부터 변했나」를 볼 때 이 시각이 +없으면 인과를 못 붙인다. + +--- + +# 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 get pods -o wide | grep keycloak +``` +**실측** — [`01-switch-to-volatile.txt`](../../evidence/a7-volatile-comparison/01-switch-to-volatile.txt) +``` +["start","--features-disabled=persistent-user-sessions"] +``` + +**어디를 봐야 하는가** — 두 가지다. + +- args 문자열이 바뀐 것 +- **파드가 실제로 새것인 것** — `AGE` 가 방금이고 `RESTARTS` 가 `0` + +StatefulSet 의 `spec` 은 바뀌었는데 파드가 옛 것이면 **선언만 바뀌고 프로세스는 +그대로**다. 그 상태에서 재면 persistent 를 재면서 volatile 이라고 적게 된다. + +IP 가 바뀌었으므로 다시 잡는다. **여기서 안 잡으면 4절이 통째로 헛돈다.** +```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" +``` + +탐침 파드의 환경변수도 낡았다. **지우고 새 IP 로 다시 띄운다.** +```bash +sudo kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found +sudo kubectl -n keycloak-lab run a7-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/a7-probe --timeout=120s +``` + +## 3-2. ★ 진짜 판정 — 로그인해도 DB 에 행이 안 생긴다 + +args 문자열만으로는 부족하다. **동작이 바뀐 것을 봐야 한다.** + +**하기** — `keycloak-0` 에만 로그인 5회 +```bash +sudo kubectl -n keycloak-lab exec a7-probe -- sh -c \ + 'for i in 1 2 3 4 5; do + curl -s -o /dev/null -w "%{http_code} " -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" + done; echo' +``` +**형태** +``` +200 200 200 200 200 +``` + +**확인** — 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" +``` +**실측** — [`02-a0-rerun.txt`](../../evidence/a7-volatile-comparison/02-a0-rerun.txt) +``` +=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) === + offline_flag | count +--------------+------- +(0 rows) +``` + +**어디를 봐야 하는가** — **`(0 rows)`.** 이것이 전환의 유일한 확실한 증거다. + +**이 결과가 의미하는 것** — 로그인 5회가 성공했는데 DB 에 아무것도 안 남았다. +세션이 메모리에만 있다. + +## 3-3. ★ 캐시 엔트리 수로는 두 모드를 구별할 수 없다 + +여기가 이 실험에서 가장 헷갈리는 자리다. + +**확인** +```bash +sudo kubectl -n observability exec deploy/prometheus -- \ + wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' +``` + +한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이 +붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. + +**형태** +```json +{"status":"success","data":{"resultType":"vector","result":[ +{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757046000.1,"5"]}, +{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757046000.1,"0"]}]}} +``` + +라벨을 보고 나면 읽기 좋게 자른다. **미검증** +```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-a0-rerun.txt`](../../evidence/a7-volatile-comparison/02-a0-rerun.txt) +``` + keycloak-0 sessions 캐시 5.0 건 + keycloak-1 sessions 캐시 0.0 건 +``` + +**어디를 봐야 하는가** — `5 / 0`. + +**이 결과가 의미하는 것** — **persistent 였을 때와 똑같은 숫자다.** +`approximate_entries_unique` 는 **그 노드가 소유한 엔트리**만 센다. 백업본을 +들고 있어도 0 으로 보인다. + +| | persistent | volatile | +|---|---|---| +| 로그인 5회 후 캐시 | `5 / 0` | `5 / 0` | +| **로그인 5회 후 DB** | **5건** | **0건** | + +**이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다.** 두 모드를 가르는 것은 +**DB 행이 있느냐**이고, 그다음은 **7800 을 끊어 보는 것**이다. 그게 4-3 이다. + +--- + +# 4. 효과를 관찰한다 — 같은 실험 네 개를 다시 돌린다 + +## 4-1. A-0 재실행 — DB 는 비었는데 교차 노드가 된다 + +**확인** — 1-4 와 **완전히 같은 명령**이다 +```bash +sudo kubectl -n keycloak-lab exec a7-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 + curl -s -o /dev/null -w "%{http_code}\n" -X POST \ + "http://$K1:8080/realms/master/protocol/openid-connect/token" \ + -d grant_type=refresh_token -d client_id=admin-cli \ + -d "refresh_token=$(cat /tmp/rt)"' +``` +**실측** — [`02-a0-rerun.txt`](../../evidence/a7-volatile-comparison/02-a0-rerun.txt) +``` +=== 교차 노드 세션은 되는가 === + keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200 +``` + +**이 결과가 의미하는 것** — **겉보기 결과가 persistent 때와 같다.** 그런데 +DB 는 0건이다(3-2). 즉 **경로가 완전히 달라졌다.** + +``` + persistent : keycloak-1 이 PostgreSQL 을 읽어서 답했다 + volatile : keycloak-1 이 7800 을 통해 keycloak-0 에게 물어서 답했다 +``` + +**같은 200 인데 다른 이유다.** 겉보기 결과만으로는 구별이 안 된다는 것이 +이 절의 요지고, 구별하려면 그 경로를 끊어 봐야 한다. + +## 4-2. A-8 재실행 — 롤링 재시작이 곧 로그아웃 + +**하기** — 재시작 **전에** 로그인해서 토큰을 파드 안에 보관한다 +```bash +sudo kubectl -n keycloak-lab exec a7-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; echo' +``` +**형태** — access token 의 가운데 토막이 클레임이다 +```json +{"exp":1757046060,"iat":1757046000,"jti":"...","typ":"Bearer","azp":"admin-cli", + "sid":"aVwYnzKZFFvMqD3bpSeiILuM",...} +``` + +**실측** — [`03-a8-rerun-restart.txt`](../../evidence/a7-volatile-comparison/03-a8-rerun-restart.txt) +``` +=== [A-8 재실행] 재시작 전 로그인 === + sid = aVwYnzKZFFvMqD3bpSeiILuM +``` + +`sid` 를 적어 둔다. + +> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다). +> `sid` 는 앞쪽에 있어서 대개 보인다. + +**★ 탐침 파드가 StatefulSet 밖에 있어야 한다.** 토큰이 재시작을 넘어 살아 +있어야 이 시험이 성립한다. `a7-probe` 는 `--restart=Never` 로 띄운 단독 파드라 +Keycloak 롤아웃과 무관하다. + +**하기** — 롤링 재시작 +```bash +date '+%H:%M:%S 재시작' +sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak +sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s +``` +**실측** — 같은 파일 +``` +statefulset.apps/keycloak restarted +partitioned roll out complete: 2 new pods have been updated... +``` + +**되돌리기** — **없다.** 롤링 재시작은 정상 작업이고 되돌릴 것이 없다. +다만 파드 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" +``` + +**★ 탐침 파드의 `K0` 환경변수는 낡았다.** 하지만 지금은 파드를 다시 띄우면 안 +된다 — **`/tmp/rt` 가 같이 사라진다.** 대신 새 IP 를 명령줄에 직접 넘긴다. + +**확인** — 재시작 전 토큰이 아직 통하는가 +```bash +sudo kubectl -n keycloak-lab exec a7-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)"' +``` +**실측** — [`03-a8-rerun-restart.txt`](../../evidence/a7-volatile-comparison/03-a8-rerun-restart.txt) +``` +=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) === + keycloak-0 에서 refresh HTTP 400 + --- 오류 본문 --- +{"error":"invalid_grant","error_description":"Session not active"} +``` + +**어디를 봐야 하는가** — `400` 과 **본문의 `Session not active`.** + +**이 결과가 의미하는 것** — **A-8 의 결과가 정확히 뒤집혔다.** 같은 명령, +같은 순서, 반대 답이다. + +| | persistent (A-8) | volatile (지금) | +|---|---|---| +| 재시작 전 토큰으로 refresh | `200` | **`400 Session not active`** | +| 배포 | 자유롭다 | **모든 사용자가 다시 로그인** | +| 파드 재시작(OOM·노드 교체) | 무해 | **그 노드가 처리하던 세션 소멸** | + +**본문을 반드시 본다.** `400` 만 보면 「토큰이 이상한가」로 읽히지만, +`Session not active` 는 **서버가 그 세션을 모른다**는 뜻이다. 토큰은 멀쩡하다. + +**확인** — 캐시는 어떻게 되었나 +```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]' +``` +**실측** — 같은 파일 +``` +=== 캐시 상태 === + keycloak-1 sessions 캐시 1.0 건 +``` + +**이 결과가 의미하는 것** — 재시작으로 캐시가 비었고, **방금 실패한 요청이 +새 세션을 하나 만든 것**이 1건이다. 옛 세션 5건은 어디에도 없다. + +> **24 이전 버전을 쓰는 곳에서 「배포하면 로그아웃된다」가 당연하게 여겨졌던 +> 이유가 이것이다.** A-8 이 「이것이 persistent 를 켜는 진짜 이유」라고 쓴 문장이 +> 여기서 증명된다. + +## 4-3. A-1 재실행 — 이번에는 세션 공유가 깨진다 + +**이 절이 이 실험의 핵심이다.** A-1 과 같은 주입, 같은 관측, 정반대 결과. + +### 왜 NetworkPolicy 가 아니라 iptables 인가 + +A-1 에서 배운 것이다. NetworkPolicy 는 **conntrack 의 ESTABLISHED 를 못 뚫는다** — +이미 붙어 있는 7800 연결은 계속 산다. A-5 가 그 벽을 넘는 방법을 확립했다. + +``` + 패킷 도착 + ├─▶ raw PREROUTING ← conntrack 보다 먼저. 여기서 끊는다 + ├─▶ conntrack: ESTABLISHED 면 통과 + └─▶ NetworkPolicy 평가 ← 여기까지 오지 않는다 +``` + +**`raw` 테이블은 CNI 가 안 쓰는 테이블**이라 규칙이 밀려나지도 않는다. + +**되돌리기** — 먼저 읽어 둔다. **두 노드 모두** +```bash +sudo iptables -t raw -F PREROUTING +ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING' +``` + +**하기** — 각 노드에 **그 노드에 있는 파드로 들어가는** 7800·57800 을 버린다 +```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}') + +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 +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 차단' +``` + +**어디에 무엇을 넣는지 헷갈리지 않는다.** + +| 노드 | 그 노드에 있는 파드 | 규칙의 `-d` | +|---|---|---| +| `kc-lab-1` | `keycloak-1` | `$K1` | +| `kc-lab-2` | `keycloak-0` | `$K0` | + +**57800 도 같이 막는다.** FD_SOCK2(장애 감지 채널)는 `bind_port + 50000` 을 쓴다. +7800 만 막으면 장애 감지가 살아 있어 분단이 어중간해진다. + +**확인** — 규칙이 걸렸고 **패킷을 실제로 세고 있나** +```bash +sudo iptables -t raw -L PREROUTING -n -v +ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' +``` +**형태** +``` +Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes) + pkts bytes target prot opt in out source destination + 19 1140 DROP tcp -- * * 0.0.0.0/0 10.42.0.46 tcp dpt:7800 + 0 0 DROP tcp -- * * 0.0.0.0/0 10.42.0.46 tcp dpt:57800 +``` + +**어디를 봐야 하는가** — **`pkts` 카운터.** 규칙이 목록에 있는데 `pkts` 가 +0 이면 **패킷이 그 경로로 안 오는 것**이고, 분단은 안 만들어졌다. A-5 가 이 함정에 +두 번 빠졌다. + +**확인** — 분단이 성립했나. 25초 간격으로 몇 번 친다 +```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]' +``` +**실측** — [`04-a1-rerun-partition.txt`](../../evidence/a7-volatile-comparison/04-a1-rerun-partition.txt) +``` + 차단 적용 (A-5 에서 확인한 raw 테이블 방식, 양방향) + 분단이 성립할 때까지 대기... + +25초 cluster_size(k0 k1) = [2.0 2.0 ] + +50초 cluster_size(k0 k1) = [1.0 ] + +75초 cluster_size(k0 k1) = [1.0 ] + +100초 cluster_size(k0 k1) = [] + +125초 cluster_size(k0 k1) = [1.0 ] +``` + +**어디를 봐야 하는가** — `2.0 2.0` 이 `1.0` 으로 떨어지는 것. **50초쯤 걸린다.** + +> **★ `[]` 와 값이 하나뿐인 줄은 측정 실패다.** 원래 실행은 20~25초마다 임시 +> 파드를 띄워 지표를 긁는 스크립트를 썼는데, 파드 생성이 느리고 경합이 있어 +> **빈 응답이 섞였다.** A-1 가이드가 지적한 그 문제가 여기서도 그대로 보인다. +> 당신은 손으로 치므로 빈 값이 나오면 그 자리에서 보이고 다시 치면 된다. +> **빈 값을 「0으로 떨어졌다」로 읽지 않는다.** + +**확인** — 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" +``` +**형태** +``` + name | ip | coord +------------------+-----------------+------- + keycloak-0-30843 | 10.42.1.99:7800 | t + keycloak-1-48749 | 10.42.0.46:7800 | t +``` + +**`coord = t` 가 둘이면 분단이다.** 정상일 때는 하나다. + +### 본 시험 — 대조군과 시험군을 같이 잰다 + +**★ 대조군을 반드시 같이 잰다.** 차단이 **모든 것을** 망가뜨린 게 아니라 +**교차 노드만** 끊었다는 것을 보여야 한다. + +**하기** — 같은 노드(대조군) +```bash +sudo kubectl -n keycloak-lab exec a7-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 + curl -s -o /dev/null -w "same-node %{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)"' +``` + +**하기** — 교차 노드(시험군). **새로 로그인해서 새 토큰으로 한다** +```bash +sudo kubectl -n keycloak-lab exec a7-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 + curl -s -w "\ncross-node %{http_code}\n" -X POST \ + "http://'"$K1"':8080/realms/master/protocol/openid-connect/token" \ + -d grant_type=refresh_token -d client_id=admin-cli \ + -d "refresh_token=$(cat /tmp/rt)"' +``` + +**실측** — [`04-a1-rerun-partition.txt`](../../evidence/a7-volatile-comparison/04-a1-rerun-partition.txt) +``` +=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) === + keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군 + keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군 + --- 시험군 오류 본문 --- +{"error":"invalid_grant","error_description":"Session not active"} +``` + +**이 결과가 의미하는 것 — 이 한 쌍이 A층 전체의 근거다.** + +``` + persistent : 세션 ── PostgreSQL ──▶ 양쪽이 본다 7800 무관 + volatile : 세션 ── 클러스터(7800) ─▶ 상대에게 간다 7800 필수 +``` + +**A-1 이 통념과 어긋난 이유가 확정됐다.** 통념은 24 이전에서 맞다. 틀린 것은 +자료가 아니라 **버전을 확인하지 않고 적용하는 것**이다. + +**하기** — 차단을 푼다. **다음 절로 넘어가기 전에 반드시 푼다** +```bash +sudo iptables -t raw -F PREROUTING +ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING' +sudo iptables -t raw -L PREROUTING -n +ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n' +``` + +**확인** — 클러스터가 다시 붙었나. 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` 로 돌아와야 4-4 로 넘어간다. **분단이 남아 있으면 4-4 의 결과가 +DB 때문인지 분단 때문인지 구별되지 않는다.** + +## 4-4. A-2 재실행 — 새 로그인은 되는데 refresh 가 안 된다 + +**되돌리기** — 먼저 읽어 둔다 +```bash +sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1 +sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s +``` + +**하기** — DB 를 내리기 **전에** 로그인해서 토큰을 확보한다 +```bash +sudo kubectl -n keycloak-lab exec a7-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"' +``` + +**하기** — PostgreSQL 을 0대로 +```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 +``` +**실측** — [`05-a2-rerun-db-loss.txt`](../../evidence/a7-volatile-comparison/05-a2-rerun-db-loss.txt) +``` +deployment.apps/postgres scaled + postgres 정지 +``` + +**`scale --replicas=0` 인 이유** — `delete pod` 은 Deployment 가 곧바로 새로 +만든다. DB 가 없는 구간을 원하는 만큼 유지할 수 있어야 두 경로를 다 잰다. + +**확인** — ① 캐시를 가진 노드에서 refresh +```bash +sudo kubectl -n keycloak-lab exec a7-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)"' +``` + +**확인** — ② 새 로그인 +```bash +sudo kubectl -n keycloak-lab exec a7-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"' +``` + +**실측** — [`05-a2-rerun-db-loss.txt`](../../evidence/a7-volatile-comparison/05-a2-rerun-db-loss.txt) +``` + ① 캐시를 가진 노드에서 refresh HTTP 500 + ② 새 로그인 HTTP 200 +``` + +**어디를 봐야 하는가** — **순서가 거꾸로다.** persistent 에서는 새 로그인이 +`500` 이었다. 세션을 DB 에 써야 했기 때문이다. 그 쓰기가 없어지니 로그인이 +통과한다. + +``` + 로그인에 필요한 것 + ├─ realm 설정 → Infinispan `realms` 캐시에 있다 + ├─ 사용자 자격 → `users` 캐시에 있다 + └─ 세션 저장 → volatile 이므로 메모리 + → DB 없이 완결된다 +``` + +### ★ 이 두 숫자를 그대로 표로 옮기면 안 된다 + +**이 결과는 조건부다.** 후속 실험 [A-7a](a7a-volatile-cause.md) 가 확정한 것: + +| 캐시 상태 | 로그인 | refresh | +|---|---|---| +| **완전 냉시동** (재시작 직후) | **400** | 400 | +| **CLIENT 만 더움** ← 위에서 잰 것 | 200 | **500** | +| **완전히 더움** | 200 | **200** | + +**같은 설정에서 캐시 온도만으로 셋으로 갈린다.** 위에서 잰 `200 / 500` 은 그중 +한 상태다 — 마침 롤아웃 뒤 로그인을 몇 번 했고 refresh 는 안 한 상태였기 때문에 +그 값이 나왔다. + +그리고 A-7 이 남긴 **「refresh 가 500 인 이유는 `REVOKED_TOKEN` 조회일 것」이라는 +가설은 틀렸다.** 실제 원인은 `CLIENT_SCOPE_CLIENT` 를 `DEFAULT_SCOPE='f'` 로 +조회하는 한 문장이고, 그것은 **문장 로깅을 켜야 보인다.** + +> **한 번 재고 표로 적으면 안 되는 종류의 측정이다.** 상태가 결과를 바꾸는데 +> 그 상태가 안 보인다. A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 +> 같은 계열의 함정이다. 셋 다 재현하는 절차는 [A-7a 가이드](a7a-volatile-cause.md) 에 있다. + +**하기** — DB 를 되살린다 +```bash +sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1 +sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s +``` +**실측** — [`05-a2-rerun-db-loss.txt`](../../evidence/a7-volatile-comparison/05-a2-rerun-db-loss.txt) +``` +deployment.apps/postgres scaled +deployment "postgres" successfully rolled out +``` + +> **volatile 이 「DB 없이 돌아간다」는 뜻은 아니다.** realm·사용자·클라이언트· +> 취소 토큰은 **여전히 DB 에 있다.** 세션만 메모리로 옮긴 것이다. + +--- + +# 5. 복구 + +## 5-1. iptables 가 남아 있지 않은지 먼저 본다 + +**확인** +```bash +sudo iptables -t raw -L PREROUTING -n +ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n' +``` + +규칙이 남아 있으면 지운다. +```bash +sudo iptables -t raw -F PREROUTING +ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING' +``` + +## 5-2. PostgreSQL 이 떠 있는지 본다 + +**확인** +```bash +sudo kubectl -n keycloak-lab get pods -l app=postgres +``` + +`Running` 이 아니면 `scale deployment/postgres --replicas=1`. + +## 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 +``` + +매니페스트를 고쳤다면 **파일도 같이 되돌린다.** 안 그러면 다음에 `apply` 할 때 +volatile 로 다시 간다. +```bash +git diff deploy/lab/k8s/keycloak-cluster.yaml +git checkout -- deploy/lab/k8s/keycloak-cluster.yaml +``` + +**실측** — [`06-restore-persistent.txt`](../../evidence/a7-volatile-comparison/06-restore-persistent.txt) +``` +=== persistent 모드로 원복 === +statefulset.apps/keycloak configured +partitioned roll out complete: 2 new pods have been updated... +``` + +## 5-4. 정말 돌아왔는지 — 로그인 후 DB 에 행이 생기는가 + +**args 문자열만 보고 끝내지 않는다.** 3-2 와 같은 이유로, 동작을 봐야 한다. + +**하기** — 새 IP 로 탐침을 다시 띄우고 로그인 한 번 +```bash +sudo kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found +K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') +sudo kubectl -n keycloak-lab run a7-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/a7-probe --timeout=120s +sudo kubectl -n keycloak-lab exec a7-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 get statefulset keycloak \ + -o jsonpath='{.spec.template.spec.containers[0].args}' ; echo +sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -tAc "select count(*) from offline_user_session where offline_flag='0'" +``` +**실측** — [`06-restore-persistent.txt`](../../evidence/a7-volatile-comparison/06-restore-persistent.txt) +``` +["start"] +로그인 + DB 온라인 세션: 1 건 (1 이면 persistent 복귀) +keycloak-0 1/1 Running 0 67s +keycloak-1 1/1 Running 0 89s +postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s + 외부 진입점 HTTP 200 +``` + +**어디를 봐야 하는가** — **`1 건`.** 2-1 에서 테이블을 비웠으므로 여기서 세는 +값은 방금 만든 세션 하나뿐이다. **0 이면 아직 volatile 이다.** + +## 5-5. 원상복구 확인표 + +| 항목 | 명령 | 돌아왔을 때 | +|---|---|---| +| args | `sudo kubectl -n keycloak-lab get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].args}'` | `["start"]` | +| 매니페스트 | `git diff deploy/lab/k8s/keycloak-cluster.yaml` | 출력 없음 | +| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` | +| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` | +| **동작** | 위 5-4 | 로그인 후 세션 행이 **생긴다** | +| iptables | `sudo iptables -t raw -L PREROUTING -n` (두 노드) | 규칙 없음 | +| 클러스터 | `vendor_cluster_size` | 양쪽 `2` | +| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a7-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 a7-probe --ignore-not-found +``` + +> **이 실험이 재지 않은 것** — volatile 상태에서 노드를 **추가**했을 때 복제 +> 트래픽이 어떻게 늘어나는지는 재지 않았다. 파드가 둘뿐이라 N² 를 볼 수 없다. + +--- + +# 막히면 + +전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다. + +| 증상 | 원인 | 확인 | +|---|---|---| +| `rollout status` 가 타임아웃 | **빌드 옵션이라 재빌드가 일어난다.** 평소보다 오래 걸린다 | `--timeout=500s` 로 다시. `logs keycloak-0` 에 빌드 진행이 보인다 | +| `apply` 가 `unchanged` | args 를 안 고쳤거나 다른 파일을 고쳤다 | `get statefulset ... -o jsonpath='{...args}'` 로 실제 값 | +| 전환했는데 DB 에 행이 그대로 | **2-1 의 `delete` 를 건너뛰었다.** 옛 행은 안 지워진다 | `delete from offline_user_session` 후 다시 로그인 | +| 캐시가 `5 / 0` 이라 전환이 안 된 것 같다 | **두 모드가 같은 값을 낸다** | 판정은 DB 행 수로 한다 — 3-3 | +| 차단했는데 `cluster_size` 가 계속 2 | 규칙이 안 걸렸거나 `pkts` 가 0 | `iptables -t raw -L PREROUTING -n -v` 의 카운터 — 4-3 | +| `cluster_size` 결과가 `[]` | **측정 실패다.** 원래 실행의 스크립트가 빈 값을 뱉었다 | 손으로 다시 친다. 빈 값은 판정에서 뺀다 | +| 교차 노드가 계속 `200` | 차단이 한쪽만 걸렸다 = 단방향 | 두 노드 카운터를 **둘 다** 본다 | +| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 다시 | +| refresh 가 `400` 인데 이유를 모르겠다 | 본문을 안 봤다 | `-o /dev/null` 을 빼고 본문을 본다. `Session not active` 인지 | +| A-2 재실행이 `200 / 200` 이 나온다 | **캐시가 이미 더워졌다.** 틀린 게 아니다 | 조건부다 — 4-4 의 표, [A-7a](a7a-volatile-cause.md) | +| 로그인이 `400 unauthorized_client` | **완전 냉시동이다.** 클라이언트 조회조차 캐시에 없다 | 이것도 조건부 — [A-7a](a7a-volatile-cause.md) | +| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 | +| 다음 실험 결과가 이상하다 | **원복을 안 했다** | 5-5 확인표를 전부 통과시킨다 | + +--- + +# 다음 + +| 실험 | A-7 이 남긴 질문 | +|---|---| +| [A-7a](a7a-volatile-cause.md) volatile 원인 확정 | **4-4 의 `500` 은 왜인가.** 가설(`REVOKED_TOKEN`)은 틀렸고, 표 자체가 조건부다 | +| [A-1](a1-jgroups-transport-block.md) 7800 차단 | **같은 주입, 정반대 결과.** 이 둘을 나란히 놓는 것이 A층의 근거다 | +| [A-8](a8-rolling-restart.md) 롤링 재시작 | 「배포하면 로그아웃」이 왜 옛 상식이었는지 | +| 전부 | **버전 확인이 1순위다.** 인터넷 자료가 틀린 게 아니라 버전이 다른 것이다 | diff --git a/docs/guides/experiments/a7a-volatile-cause.md b/docs/guides/experiments/a7a-volatile-cause.md new file mode 100644 index 0000000..9f92450 --- /dev/null +++ b/docs/guides/experiments/a7a-volatile-cause.md @@ -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 : 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 /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 에서는 캐시 온도와 무관하게 실패한다 — 대조군 | +| 전부 | **한 번 재고 표로 적으면 안 되는 종류가 있다.** 상태가 결과를 바꾸는데 그 상태가 안 보일 때 | diff --git a/docs/guides/experiments/a8-rolling-restart.md b/docs/guides/experiments/a8-rolling-restart.md new file mode 100644 index 0000000..53a0a3f --- /dev/null +++ b/docs/guides/experiments/a8-rolling-restart.md @@ -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** 의 조합이다 | diff --git a/docs/guides/experiments/b0-bff-redis-deploy.md b/docs/guides/experiments/b0-bff-redis-deploy.md new file mode 100644 index 0000000..b60a981 --- /dev/null +++ b/docs/guides/experiments/b0-bff-redis-deploy.md @@ -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"` 로 시작하는 것. +` /' \ + | 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 "", 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` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 | diff --git a/docs/guides/experiments/b1-redis-session-store.md b/docs/guides/experiments/b1-redis-session-store.md new file mode 100644 index 0000000..ba8e311 --- /dev/null +++ b/docs/guides/experiments/b1-redis-session-store.md @@ -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 ← 이게 문제 +``` + +**`_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 + + + org.springframework.session + spring-session-data-redis + + + org.springframework.boot + spring-boot-starter-data-redis + +``` + +**★ 하나만 넣으면 조용히 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 로 부른다 +- 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 이름과 환경변수 충돌 | diff --git a/docs/guides/experiments/b2-multi-instance-session.md b/docs/guides/experiments/b2-multi-instance-session.md new file mode 100644 index 0000000..5fd3c96 --- /dev/null +++ b/docs/guides/experiments/b2-multi-instance-session.md @@ -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 "", 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** 과 **로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 | diff --git a/docs/guides/experiments/b3-refresh-token-contention.md b/docs/guides/experiments/b3-refresh-token-contention.md new file mode 100644 index 0000000..b2203f5 --- /dev/null +++ b/docs/guides/experiments/b3-refresh-token-contention.md @@ -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 행 잠금 | diff --git a/docs/guides/experiments/b4-edge-authorization-scope.md b/docs/guides/experiments/b4-edge-authorization-scope.md new file mode 100644 index 0000000..1aaccf8 --- /dev/null +++ b/docs/guides/experiments/b4-edge-authorization-scope.md @@ -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 구조가 맞다.** 두 구조가 같은 실험대에 다 있다 | diff --git a/docs/guides/experiments/b5-redis-loss-persistence.md b/docs/guides/experiments/b5-redis-loss-persistence.md new file mode 100644 index 0000000..e5c17ee --- /dev/null +++ b/docs/guides/experiments/b5-redis-loss-persistence.md @@ -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` 으로 바꾼다 | diff --git a/docs/guides/experiments/b6-key-rotation.md b/docs/guides/experiments/b6-key-rotation.md new file mode 100644 index 0000000..bb0906b --- /dev/null +++ b/docs/guides/experiments/b6-key-rotation.md @@ -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` 필터 하나가 조용히 실패했다 | diff --git a/docs/guides/experiments/b7-cookie-secret-rotation.md b/docs/guides/experiments/b7-cookie-secret-rotation.md new file mode 100644 index 0000000..0486d8e --- /dev/null +++ b/docs/guides/experiments/b7-cookie-secret-rotation.md @@ -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=|| + └─ 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: , 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: +``` + +**어디를 봐야 하는가** — **두 줄이 다른 말을 하고 있다.** + +| 줄 | 뜻 | +|---|---| +| `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- +``` + +**왜 여기 나오나.** 티켓 전체가 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 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 | diff --git a/docs/guides/experiments/b7a-orphan-session.md b/docs/guides/experiments/b7a-orphan-session.md new file mode 100644 index 0000000..dffd308 --- /dev/null +++ b/docs/guides/experiments/b7a-orphan-session.md @@ -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: , 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 +``` +![고아 삭제 후 살아있는 세션](../../evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png) + +**이 결과가 의미하는 것** — **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 로 기록**해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 | diff --git a/docs/guides/experiments/c1-multi-app-sso.md b/docs/guides/experiments/c1-multi-app-sso.md new file mode 100644 index 0000000..6d6328c --- /dev/null +++ b/docs/guides/experiments/c1-multi-app-sso.md @@ -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 +``` + +**어디를 봐야 하는가** — **로그인 화면이 뜨는가.** + +**실측** — 뜨지 않았다. +![app2 가 로그인 없이 열린다](../../evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png) + +**이 결과가 의미하는 것** — **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 +``` + +**어디를 봐야 하는가** — **로그인 화면이 뜨는가.** + +**실측** — 둘 다 로그인 화면 없이 그대로 열렸다. +![IdP 세션이 없어도 앱은 동작한다](../../evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png) + +> **★ 증거의 정직성에 관한 주의** — 위 스크린샷과 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/` 가 조용히 안 먹는다 | 같은 유형 | `users//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 일부 소실」로 갈렸다 | +| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** | diff --git a/docs/guides/experiments/c2-backchannel-logout.md b/docs/guides/experiments/c2-backchannel-logout.md new file mode 100644 index 0000000..f7a8b84 --- /dev/null +++ b/docs/guides/experiments/c2-backchannel-logout.md @@ -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) 와 연결 | 로그아웃이 지우는 것은 지금도 **세 곳 중 하나뿐**이다 | +| 방법론 | **주입 대상이 실제로 존재하는지 먼저 확인한다.** 이 실험이 그걸로 한 번 헛돌았다 | diff --git a/docs/guides/experiments/d1-backup-restore.md b/docs/guides/experiments/d1-backup-restore.md new file mode 100644 index 0000000..065ff30 --- /dev/null +++ b/docs/guides/experiments/d1-backup-restore.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` 이 아니라 데이터 대조로 한다 | diff --git a/docs/guides/experiments/d2-version-upgrade.md b/docs/guides/experiments/d2-version-upgrade.md new file mode 100644 index 0000000..19880f2 --- /dev/null +++ b/docs/guides/experiments/d2-version-upgrade.md @@ -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` 의 행 수다** | +| 전부 | **재기 전에 못 재는 값을 먼저 적어 둔다.** 업그레이드 전 행 수가 그것이다 | diff --git a/docs/guides/experiments/d3-secret-management.md b/docs/guides/experiments/d3-secret-management.md new file mode 100644 index 0000000..d23d947 --- /dev/null +++ b/docs/guides/experiments/d3-secret-management.md @@ -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//environ` 은 그 +프로세스의 환경변수를 그대로 담고 있고, **같은 UID 의 아무 프로세스나 읽는다.** + +| 새는 경로 | | +|---|---| +| `kubectl exec` 권한이 있는 사람 | 바로 본다 | +| 같은 파드의 다른 프로세스 | `/proc//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 이 유일하게 동작하는 방어선이다.** 관리자에게는 아무 방어가 없다 | diff --git a/docs/guides/experiments/d4-certificate-renewal.md b/docs/guides/experiments/d4-certificate-renewal.md new file mode 100644 index 0000000..2237a68 --- /dev/null +++ b/docs/guides/experiments/d4-certificate-renewal.md @@ -0,0 +1,1163 @@ +# D-4 재현 가이드 — 갱신은 성공했는데 왜 옛 인증서가 나가는지 직접 본다 + +해설 문서: [`docs/experiment-d4-certificate-renewal.md`](../../experiment-d4-certificate-renewal.md) · +증거 원문: [`docs/evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/) + +## 이 가이드가 끝나면 + +당신 터미널에서 이것들을 **직접 본다.** + +| 보게 되는 것 | 어디서 | +|---|---| +| 이름 세 개가 한 인증서에 들어 있는 것 (와일드카드 아님) | `openssl s_client` | +| 체인이 4단계이고 `Verify return code: 0` 인 것 | 같은 명령 | +| 타이머는 `SUCCESS` 인데 **reload 를 부르는 것이 아무 데도 없는** 것 | `systemctl cat` · 훅 3개 디렉터리 | +| nginx 워커가 **22.4시간째 그대로**인 것 = reload 가 한 번도 없었다 | `ps -eo lstart` | +| 두 기계 시계가 **106초** 어긋나 있는 것 | `date` · `Date:` 헤더 | +| 갱신에 성공했는데 밖에서 본 일련번호가 **안 바뀌는** 것 | 5초 간격 감시 | +| 사람이 reload 한 **그 순간** 바뀌는 것 | 같은 감시 | +| reload 가 **정말 무중단**인 것 | 0.2초 폴링 · 42초짜리 전송 중 요청 | + +## 전제 + +- **이 실험만은 클러스터가 아니라 호스트를 본다.** `kubectl` 은 한 번도 안 쓴다. +- 관찰은 **당신 개발 머신(dev)에서** 한다. 밖에서 본 것이 이 실험의 답이고, + **dev 의 시계가 이 실험대에서 유일하게 정확한 시계**이기 때문이다(1-7). +- 호스트(`test-server`)에는 `ssh test-server` 로 붙는다. +- **호스트의 `sudo` 는 비밀번호를 요구한다.** 게스트(`kc-lab-1`/`2`)는 무암호지만 + 호스트는 다르다. **그래서 몇 단계는 사람이 직접 쳐야 한다** — 2-1 에 표로 있다. +- 이 호스트의 certbot 은 **5.7.0**, 플러그인은 `dns-cloudflare` · `manual` · + `null` · `standalone` · `webroot` 다. **`nginx` 플러그인은 없다.** +- [`04-TLS`](../04-tls/) 단계를 이미 밟았다면 여기 나오는 결론이 그 문서의 + 5절에 요약되어 있다. 이 가이드는 그것을 **어떻게 측정했는지**를 적는다. + +## 주의 — 이건 진짜 인증서를 발급하는 실험이다 + +`certbot renew --force-renewal` 은 **되돌릴 수 없다.** 새 인증서가 실제로 +발급되고, **Let's Encrypt 의 발급 한도(주당 중복 인증서 5장)를 한 장 깎는다.** +그러므로 + +- **먼저 `--dry-run` 으로 절차만 확인한다**(2-2), +- 강제 갱신은 **이 실험 전체에서 한 번만** 쓴다, +- 그 한 번을 헛되게 쓰지 않도록 **대조군을 먼저 잡는다**(1-8). + +옛 인증서는 무효가 되지 않는다. 만료 전까지는 그대로 유효하므로 **서비스가 +깨지지는 않는다.** 다만 되돌릴 수 없으므로 순서를 지킨다. 밖에서 보이는 +인증서를 디스크와 다시 맞추는 것은 [5-1. reload](#5-1-reload) 한 줄이다. + +## 표시 규약 + +| 표시 | 뜻 | +|---|---| +| **실측** | 2026-09-04 감시 구간 08:10:51–09:02 UTC 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 | +| **실측(호스트)** | 증거 파일이 아니라 **이 호스트에서 확인된 설정값** | +| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 | +| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태이거나, 이 실험이 하지 않은 확장 | + +## ★ 시각 표기 규약 — 이 가이드에만 있다 + +**이 실험은 두 기계의 시계를 섞어 빼는 바람에 숫자를 한 번 틀렸다.** 그래서 +이 문서는 모든 시각에 **어느 시계인지** 붙인다. + +| 표기 | 뜻 | +|---|---| +| `08:58:52 (dev)` | 개발 머신 시계. 외부 기준과 일치한다 | +| `17:22:13 KST (ts)` | test-server 시계. **106초 빠르다** | +| `08:20:27 (실제)` | 보정한 값 | + +--- + +# 0. 왜 이 실험을 하는가 + +인증서 갱신 자동화는 대개 여기까지 확인하고 끝난다. + +```bash +systemctl list-timers certbot-renew.timer # 돈다 +journalctl -u certbot-renew.service # SUCCESS +``` + +**이 실험은 그 뒤를 묻는다.** 갱신된 인증서를 **누가 서버에 읽히는가.** + +``` + ① certbot 이 새 인증서를 받는다 ← 타이머가 책임진다 + ② 파일이 디스크에 써진다 ← certbot 이 한다 + ③ nginx 가 그 파일을 다시 읽는다 ← ★ 누가? +``` + +**「갱신 성공」과 「새 인증서 서빙」은 다른 사건이다.** ③ 을 하는 것이 +아무것도 없으면, ①②는 매번 성공하고 **사용자는 만료된 인증서를 본다.** + +그리고 이 결함은 **88일 동안 보이지 않는다.** 타이머는 매일 두 번 돌고 매번 +`SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할 +기회가 없고, 발현하는 날의 증상은 **인증서 만료**다 — 그날에도 로그는 +`SUCCESS` 라고 적혀 있다. + +**부수 질문이 하나 더 있다.** ③ 을 실제로 하면(= nginx reload) **진행 중이던 +요청은 어떻게 되는가.** 「nginx reload 는 무중단」이라고 다들 말하지만 이 +실험대는 그것을 재 본 적이 없었고, **재 보지 않은 명제는 쓰지 않는다**는 +규칙에 따라 유보해 뒀다. 여기서 잰다. + +--- + +# 1. 기준선 — 강제 갱신을 걸기 전에 + +**사람이 칠 수 있는 명령은 사실상 한 번뿐이다**(강제 갱신). 그 한 번을 헛되게 +쓰지 않으려면 **주입 전에 잴 것을 전부 재 둬야 한다.** + +``` +인증서 → 체인 → 이름 → 타이머 → ★ 누가 reload 하나 → 워커 PID → ★ 시계 → 대조군 +``` + +## 1-1. 밖에서 본 인증서 — 읽는 형태부터 + +**확인** — 처음 한 번은 협상 과정을 통째로 읽는다 +```bash +curl -v https://auth.hyeonworks.com/realms/master -o /dev/null +``` + +`*` 로 시작하는 줄에서 TLS 판·subject·issuer·`SSL certificate verify ok.` 를 +본다. **TLS 에서 막힐 때 봐야 할 것이 전부 여기 있다.** + +이제 인증서 자체를 뜯는다. + +**확인** +```bash +echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ + | openssl x509 -noout -serial -dates -subject -ext subjectAltName +``` +**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt) +``` +subject=CN = auth.hyeonworks.com +issuer=C = US, O = Let's Encrypt, CN = YE2 +notBefore=Sep 3 00:47:23 2026 GMT +notAfter=Dec 2 00:47:22 2026 GMT +X509v3 Subject Alternative Name: + DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com +``` + +**어디를 봐야 하는가** + +- **`serial`** — 이 값이 바뀌는 것이 「새 인증서를 서빙한다」의 정의다. + **적어 둔다.** 감시 전체가 이 값을 본다 +- `notAfter` — 만료 +- **SAN 이 세 줄이고 와일드카드가 아니다** + +### ★ `notBefore` 를 발급 시각으로 읽지 않는다 + +**Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트한다.** 클라이언트 +시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다. + +즉 위 인증서의 `notBefore=Sep 3 00:47:23` 은 **발급 시각이 아니다.** +그렇다고 여기에 한 시간을 더한 값을 발급 시각으로 그대로 쓰지도 않는다 — +이 실험대의 두 인증서에서 **CT 로그의 SCT 가 그보다 약 89초 앞선다**(1-1 뒤의 +확인, 그리고 [D-4a](d4a-deploy-hook.md) 5절). + +**시각의 외부 기준이 필요하면 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 +``` +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` + Log ID: C2:31:7E:57:...:52:CD Timestamp: Sep 3 01:45:53.183 2026 GMT + Log ID: 46:AF:86:3D:...:50:5F Timestamp: Sep 3 01:45:53.352 2026 GMT +``` + +**어디를 봐야 하는가** — `Timestamp` 두 개. **이건 CT 로그가 자기 시계로 찍은 +시각**이며, 이 실험대의 어느 기계와도 무관한 제3의 기준이다. 1-7 에서 시계가 +어긋난 것이 드러났을 때 이 값이 심판이 된다. + +## 1-2. 체인이 완전한가 — 흔한 실수 하나 + +**확인** +```bash +echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ + | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code' +``` +**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt) +``` + 0 s:CN = auth.hyeonworks.com + 1 s:C = US, O = Let's Encrypt, CN = YE2 + 2 s:C = US, O = ISRG, CN = Root YE + 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2 +Verify return code: 0 (ok) +``` + +**어디를 봐야 하는가** — **번호가 몇까지 가는가**, 그리고 마지막 줄. + +| 파일 | 내용 | nginx 에 넣으면 | +|---|---|---| +| `cert.pem` | **리프만** | **일부 클라이언트에서 검증 실패** | +| **`fullchain.pem`** | 리프 + 중간 | 정상 | + +**단계가 1개면 `cert.pem` 을 쓴 것이다.** 브라우저는 중간 인증서를 캐시하거나 +AIA 로 보완해서 **대개 정상으로 보이고**, 캐시가 없는 클라이언트(모바일 앱, +curl, 다른 서버)에서만 깨진다. **그래서 발견이 늦다.** 이 명령이 유일하게 +믿을 수 있는 판정이다. + +이 실험대는 4단계로 정상이다. + +## 1-3. 이름 세 개가 한 장인가 + +**확인** +```bash +for H in auth app1 app2; do + echo "-- $H.hyeonworks.com" + echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \ + | openssl x509 -noout -serial +done +``` + +**어디를 봐야 하는가** — 세 일련번호가 **서로 같은가.** 값 자체는 의미가 없고 +일치 여부만 본다. + +**이 결과가 의미하는 것** — 같으면 SAN 하나에 이름 셋이 든 **한 장**이고, +갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 **훅도 장마다 돌고**, +한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다. + +> **이 제약이 B-7 에서 실제 비용을 만들었다.** oauth2-proxy 를 올릴 네 번째 +> 호스트명이 없어 **Grafana 가 쓰던 `app2` 를 빌려야 했고**, 그동안 관측 +> 스택의 웹 UI 가 내려가 있었다. +> +> **「인증서에 이름을 몇 개 넣을 것인가」는 TLS 설정이 아니라 나중에 무엇을 +> 배포할 수 있는가를 정하는 결정이다.** + +## 1-4. 갱신 자동화는 도는가 + +**여기까지는 sudo 없이 읽힌다.** 실제로 이 실험대가 그 범위에서 다 읽었다. + +**확인** +```bash +ssh test-server 'systemctl list-timers certbot-renew.timer' +``` +**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt) +``` +NEXT LEFT LAST PASSED UNIT +Fri 2026-09-04 17:03:46 KST 1h 54min Fri 2026-09-04 03:19:39 KST 11h ago certbot-renew.timer +타이머 enabled: enabled +타이머 active: active +``` + +**어디를 봐야 하는가** — `NEXT`/`LEFT` 가 채워져 있는가, `LAST`/`PASSED` 가 +하루 안쪽인가. **표가 통째로 비면 타이머가 없는 것이다**(이름이 배포판마다 +다르다 — `systemctl list-timers --all | grep -i certbot`). + +**확인** — 실제로 돌았고 성공했는가 +```bash +ssh test-server 'systemctl status certbot-renew.service' +ssh test-server 'journalctl -u certbot-renew.service --since today' +``` +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` + Active: inactive (dead) since Fri 2026-09-04 17:04:11 KST + Process: 28452 ExecStart=/usr/bin/certbot -q renew (code=exited, status=0/SUCCESS) + + Sep 04 03:19:39 Starting Renew certificates acquired via Certbot... + Sep 04 03:19:41 Finished Renew certificates acquired via Certbot. + Sep 04 17:04:09 Starting Renew certificates acquired via Certbot... + Sep 04 17:04:11 Finished Renew certificates acquired via Certbot. +``` + +**어디를 봐야 하는가** — `status=0/SUCCESS`, 그리고 오늘 **두 번** 돌았다는 것. + +**이 결과가 의미하는 것** — **여기서 확인을 멈추면 「괜찮다」로 끝난다.** +대부분의 문서가 여기까지다. 그런데 남은 기간을 보면 **아직 갱신은 하지도 +않았다.** + +**확인** +```bash +echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ + | openssl x509 -noout -enddate +``` +**실측** +``` +만료: Dec 2 00:47:22 2026 GMT +남은 일수: 88일 +``` + +Let's Encrypt 는 90일 발급이고 certbot 은 **30일 남았을 때** 갱신한다. +**즉 실제 갱신까지 약 58일 남았고, 그때까지 이 절차는 한 번도 시험되지 +않는다.** 「타이머가 active 니까 괜찮다」가 확인이 아닌 이유가 이것이다. + +## 1-5. ★ 그런데 무엇이 nginx 를 reload 하는가 — 세 곳을 본다 + +**갱신된 인증서를 서버에 읽히는 경로는 셋뿐이다.** 셋을 하나씩 연다. + +### ① 갱신 유닛이 뭔가 더 하는가 + +**확인** +```bash +ssh test-server 'systemctl cat certbot-renew.service' +ssh test-server 'systemctl cat certbot-renew.timer' +``` +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` + # /usr/lib/systemd/system/certbot-renew.service + [Unit] + Description=Renew certificates acquired via Certbot + [Service] + Type=oneshot + ExecStart=/usr/bin/certbot -q renew + PrivateTmp=true + + OnCalendar=*-*-* 00/12:00:00 + RandomizedDelaySec=12h + Persistent=true +``` + +**어디를 봐야 하는가** — `ExecStart=` 한 줄, 그리고 그 아래에 **`ExecStartPost=` +가 있는지 없는지.** `ExecStart` 의 인자에 `--deploy-hook` 이 붙어 있는지도 본다. +**여기 없는 것을 보는 것이 이 명령의 목적이다.** + +**이 결과가 의미하는 것** — `ExecStart` 가 전부다. **배포판(Arch)이 넣어준 +기본 유닛이 그렇다.** 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 +것을 누가 읽게 만드는 일은 **아무도 하지 않는다.** + +> `systemctl cat` 은 **유닛 파일에 적힌 것**을, `systemctl show` 는 **기본값까지 +> 합쳐 실제 적용되는 것**을 보여 준다. 여기서는 「적혀 있지 않다」가 답이므로 +> `cat` 이 맞다. + +### ② 훅 디렉터리에 뭐가 있는가 — **여기부터 root 가 필요하다** + +**확인** — sudo 없이 쳐 보면 이렇게 나온다 +```bash +ssh test-server 'ls -laR /etc/letsencrypt/renewal-hooks/' +``` +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` +ls: cannot access '/etc/letsencrypt/renewal-hooks/': Permission denied +``` + +**이 빈 출력을 「비어 있다」로 읽으면 안 된다.** 실제로 이 실험대는 B-7 에서 +같은 실수를 했다 — nginx 설정을 읽으려던 시도가 계속 빈 결과였는데, 그게 +sudo 의 조용한 실패였다는 것을 한참 뒤에 알았다. + +**하기** — 사람이 비밀번호를 친다 +```bash +ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/ \ + /etc/letsencrypt/renewal-hooks/post/ /etc/letsencrypt/renewal-hooks/pre/' +``` +**실측** — [`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 .. + +/etc/letsencrypt/renewal-hooks/post/: +total 8 +... +/etc/letsencrypt/renewal-hooks/pre/: +total 8 +... +``` + +**어디를 봐야 하는가** — **`total 8` 과 `.` `..` 뿐.** 셋 다 비었다. + +> **`ssh -t` 의 `-t` 가 필요하다.** tty 를 붙여 줘야 sudo 가 비밀번호를 물어볼 +> 수 있다. 없으면 「비밀번호가 필요하다」에서 끝난다. + +### ③ certbot 이 스스로 고칠 수 있는가 + +**하기** — 사람이 친다 +```bash +ssh -t test-server 'sudo certbot plugins' +``` +**실측** — [`13-verdict.txt`](../../evidence/d4-certificate-renewal/13-verdict.txt) +``` + Discovered plugins: dns-cloudflare, manual, null, standalone, webroot + (certbot 5.7.0) +``` + +**어디를 봐야 하는가** — 목록에 **`nginx` 가 없다.** `certbot --nginx` 로 받은 +인증서라면 certbot 이 nginx 설정을 직접 만지고 reload 까지 하는데, 이 호스트는 +`webroot` 로 받았고 nginx 플러그인 자체가 설치되어 있지 않다. + +### 세 곳이 전부 비어 있다 + +| # | 경로 | 상태 | +|---|---|---| +| 1 | `certbot-renew.service` 의 `ExecStartPost` | **없다** | +| 2 | `renewal-hooks/{deploy,post,pre}/` | **셋 다 비었다** | +| 3 | certbot 의 nginx 플러그인 | **없다** | + +**하나라도 있었으면 자동으로 반영됐을 것이다.** 이 표가 이 실험의 원인 진단이고, +아직 아무것도 주입하지 않은 상태에서 이미 나왔다. + +## 1-6. ★ nginx 워커 PID — 판정 기준을 여기서 세운다 + +**「reload 됐는가」를 로그 문구로 판정하지 않는다.** 프로세스로 판정한다. + +**확인** +```bash +ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" +``` +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` + 585 1 80529 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx + 586 585 80529 Thu Sep 3 19:00:39 2026 nginx: worker process +``` + +**어디를 봐야 하는가 — 네 칸을 다 본다.** + +``` + 585 1 80529 Thu Sep 3 19:00:39 nginx: master process + 586 585 80529 Thu Sep 3 19:00:39 nginx: worker process + │ │ │ │ + │ │ │ └─ lstart: 이 프로세스가 뜬 시각 + │ │ └─ etimes: 떠 있는 초 (80529초 = 22.4시간) + │ └─ ppid: 부모. 워커의 부모가 마스터다 + └─ pid +``` + +**reload 는 마스터를 유지한 채 워커만 새로 띄운다.** 그러므로 + +| 마스터 PID | 워커 PID | 판정 | +|---|---|---| +| 그대로 | **바뀜** | **reload 됐다** | +| 그대로 | 그대로 | reload 가 없었다 | +| 바뀜 | 바뀜 | reload 가 아니라 **재시작**이다 | + +**이 결과가 의미하는 것** — 마스터 585, 워커 586. **번호가 붙어 있다** — 마스터 +기동 직후의 첫 fork 그대로다. 둘의 `lstart` 가 같고 `etimes` 도 같다. +**즉 22.4시간 동안 reload 가 한 번도 없었다.** + +**이 두 줄을 적어 둔다.** 4-2 와 5-2 에서 이 값과 비교한다. + +## 1-7. ★ 시계를 먼저 잰다 — 나중에 재면 늦는다 + +**두 기계의 로그를 나란히 놓기 전에 확인한다.** 이 실험은 이걸 나중에 하는 +바람에 공백 수치를 한 번 틀렸다. + +**확인** — 왕복 사이에 상대 시각을 끼워 잰다 +```bash +A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N) +echo "$A"; echo "$B"; echo "$C" +``` + +**어디를 봐야 하는가** — 세 수를 **눈으로 뺀다.** `A` 와 `C` 는 같은 기계에서 +SSH 왕복 직전·직후에 찍은 것이므로, 그 가운데가 「저쪽 시각을 잰 순간의 이쪽 +시각」이다. `B` 가 그보다 크면 저쪽이 빠른 것이다. + +**확인** — 어느 쪽이 맞는지는 **외부 기준**으로 가른다 +```bash +curl -sI https://www.google.com | grep -i '^date:' +curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:' +date -u +ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized' +``` +**실측** — [`d4a-deploy-hook/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` 다. +그리고 세 번 재서 값이 흔들리지 않는 것. + +**이 결과가 의미하는 것** — **dev 가 정확하고 test-server 가 106초 빠르다.** + +``` + 실제 시각 = test-server 시계 − 106초 + 실제 시각 = dev 시계 (보정 불필요) +``` + +**그러므로 이 실험의 모든 관측은 dev 에서 한다.** 호스트에서만 알 수 있는 +값(파일 mtime, 훅 로그)은 **보정해서** 쓴다. + +> **왜 이걸 주입 전에 하나.** 주입 후에는 「그때 저 시계가 얼마나 어긋나 +> 있었나」를 되짚을 수 없다. 그리고 이 실험은 실제로 **보정 없이 뺀 값 +> 2199초를 문서에 적었다가 나중에 2305초로 정정했다.** + +## 1-8. 대조군 — 근거를 재려면 (선택) + +**여기부터는 「무중단인가」를 문서에 남길 근거가 필요할 때만 한다.** +일련번호가 언제 바뀌는지만 보려면 1-1 의 명령을 손으로 두 번 치면 된다. + +주입 중에 오류가 한 번 나왔을 때 **평시 오류율을 모르면 아무것도 증명하지 +못한다.** 그래서 대조군을 먼저 잡는다. + +### 대조군 ① — 새 연결 + +**하기** — 0.2초 × 900회 = 180초 +```bash +i=0 +while [ $i -lt 900 ]; do + curl -s -o /dev/null -w '%{http_code} %{time_total} %{time_appconnect}\n' \ + --max-time 5 https://auth.hyeonworks.com/realms/master + i=$((i+1)); sleep 0.2 +done > /tmp/d4-control.txt +awk '{print $1}' /tmp/d4-control.txt | sort | uniq -c +``` +**실측** — [`05-control-no-injection.txt`](../../evidence/d4-certificate-renewal/05-control-no-injection.txt) +``` +표본 900 개 + +[상태코드 분포] + 900 200 + +[응답시간 ms] + 최소 67 중앙 98 p95 195 최대 1121 평균 106.9 + +[TLS 핸드셰이크 ms — 0 이면 연결 재사용, >0 이면 새 핸드셰이크] + 핸드셰이크 발생 900회 / 900 평균 83 ms 최대 1100 ms + +[비정상 응답 원문 — 있으면 아래에 전부] + 비200 총 0 +``` + +**어디를 봐야 하는가** — `uniq -c` 의 줄이 **하나**이고 그 값이 `900 200` 인가. +그리고 **핸드셰이크가 900/900** 이라는 것. + +**이 결과가 의미하는 것** — 대조군이 깨끗하다. 그래서 주입 중 비200 이 한 번만 +나와도 주입 탓으로 귀속할 수 있다. **대조군에 이미 오류가 섞여 있으면 주입을 +하지 않는다** — 판정할 수 없기 때문이다. + +그리고 핸드셰이크 900/900 은 **매 요청이 새 연결**이라는 뜻이다. 즉 이 장치는 +**「새 연결을 받아주는가」만 잰다.** 계획서가 물은 것은 「진행 중이던 요청은 +어떻게 되는가」이므로 장치가 하나 더 필요하다. + +### 대조군 ② — 진행 중이던 요청 + +**reload 순간에 실제로 전송 중인 요청이 있어야 한다.** 845KB 짜리 관리 콘솔 +번들을 일부러 느리게 받아 요청 하나를 **42초 동안 살려 둔다.** + +**하기** — 먼저 큰 파일의 경로를 찾는다(버전마다 달라진다) +```bash +JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \ + | grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1) +echo "$JS" +``` +**실측** — [`06-inflight-control.txt`](../../evidence/d4-certificate-renewal/06-inflight-control.txt) +``` + 대상: https://auth.hyeonworks.com/resources/55yjq/admin/keycloak.v2/assets/main-BbID33M6.js +``` + +**하기** +```bash +curl -s --limit-rate 20k -o /tmp/inflight.bin \ + -w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \ + "https://auth.hyeonworks.com$JS" +``` +**실측** — [`06-inflight-control.txt`](../../evidence/d4-certificate-renewal/06-inflight-control.txt) +``` +[대조군: 주입 없이 1회] + 코드=200 받은바이트=845361 총시간=41.392198s 연결수=1 실효속도=20423B/s + 기대 크기 845361 / 실제 845361 bytes + +판정 기준 (주입 시 이 값들과 비교한다) + · 코드 200 + 크기 845361 = 진행 중이던 요청이 끝까지 살아남았다(graceful) + · 코드 000 또는 크기 부족 = reload 가 진행 중이던 연결을 끊었다 + · 연결수 2 이상 = 중간에 끊겨 curl 이 다시 붙었다 +``` + +**어디를 봐야 하는가** — **`연결수=1`.** 이게 판정의 핵심이다. 끊겼다가 curl 이 +다시 붙었으면 2 가 된다. + +### 감시를 켠다 — 여기서부터는 파일로 만든다 + +**세 감시가 동시에 돌아야 하고, 각각 루프와 종료 조건이 있다.** 이쯤 되면 +한 줄 명령이 아니라 프로그램이다. **파일로 쓴다.** + +```bash +vim /tmp/d4-watch-serial.sh +``` +```sh +#!/bin/sh +# file: /tmp/d4-watch-serial.sh +# 5초마다 밖에서 본 인증서의 일련번호와 만료일을 찍는다. +# /tmp/d4-stop 파일이 생기면 멈춘다. +HOST=auth.hyeonworks.com +while [ ! -f /tmp/d4-stop ]; do + S=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null \ + | openssl x509 -noout -serial -enddate | tr '\n' ' ') + echo "$(date -u +%H:%M:%S) $S" + sleep 5 +done +``` + +```bash +vim /tmp/d4-poll.sh +``` +```sh +#!/bin/sh +# file: /tmp/d4-poll.sh +# 0.2초마다 새 연결 하나. 상태코드와 소요 시간만 남긴다. +while [ ! -f /tmp/d4-stop ]; do + echo "$(date -u +%H:%M:%S.%2N) $(curl -s -o /dev/null \ + -w '%{http_code} %{time_total}' --max-time 5 \ + https://auth.hyeonworks.com/realms/master)" + sleep 0.2 +done +``` + +```bash +vim /tmp/d4-inflight.sh +``` +```sh +#!/bin/sh +# file: /tmp/d4-inflight.sh +# 42초짜리 요청을 끊김 없이 연달아 돌린다 — reload 순간에 반드시 하나가 떠 있게. +# ★ curl 의 종료 코드를 반드시 남긴다. 안 남기면 측정 장치의 실패와 +# 서버의 실패를 구별할 수 없다 (08-inflight-artifact.txt). +URL="https://auth.hyeonworks.com$1" +while [ ! -f /tmp/d4-stop ]; do + R=$(curl -s --limit-rate 20k -o /dev/null \ + -w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}' \ + "$URL"); E=$? + echo "$(date -u +%H:%M:%S) $R curl종료=$E" + [ $E -ne 0 ] && sleep 1 +done +``` + +**하기** +```bash +chmod +x /tmp/d4-watch-serial.sh /tmp/d4-poll.sh /tmp/d4-inflight.sh +rm -f /tmp/d4-stop +setsid /tmp/d4-watch-serial.sh > /tmp/d4-serial.txt 2>&1 < /dev/null & +setsid /tmp/d4-poll.sh > /tmp/d4-poll.txt 2>&1 < /dev/null & +setsid /tmp/d4-inflight.sh "$JS" > /tmp/d4-inflight.txt 2>&1 < /dev/null & +``` + +**되돌리기** — 셋 다 멈춘다 +```bash +touch /tmp/d4-stop +``` + +> **`setsid` 가 필요하다.** 그냥 `&` 로 띄우면 부모 셸이 끝날 때 같이 죽는다 +> (A-3 에서 파드 안 `&` 가 `exec` 종료와 함께 죽은 것과 같은 함정이다). +> 이 실험은 사람이 다른 창에서 sudo 를 치는 동안 감시가 살아 있어야 한다. + +**확인** — 30초쯤 두고 감시가 실제로 쌓이는지 본다 +```bash +tail -3 /tmp/d4-serial.txt +tail -3 /tmp/d4-poll.txt +tail -3 /tmp/d4-inflight.txt +``` + +**어디를 봐야 하는가** — 세 파일 다 줄이 늘고 있는가. **여기서 비어 있으면 +주입해도 아무것도 안 남는다.** + +--- + +# 2. 주입 — 강제 갱신 (사람이 친다) + +## 2-1. 무엇을 사람이 쳐야 하나 + +| 하는 일 | 어디서 | sudo | +|---|---|---| +| 밖에서 인증서·체인·SAN 읽기 | dev | 필요 없다 | +| 타이머·유닛·journal 읽기 | test-server | **필요 없다** (이 실험대에서 확인) | +| nginx 워커 PID 읽기 | test-server | 필요 없다 | +| nginx 설정에서 인증서 경로 찾기 | test-server | 필요 없다 | +| **훅 디렉터리 보기** | test-server | **비밀번호** | +| **`certbot certificates` · `archive/` 보기** | test-server | **비밀번호** | +| **`certbot renew --force-renewal`** | test-server | **비밀번호** | +| **`nginx -s reload`** | test-server | **비밀번호** | + +**호스트에서 비대화 sudo 는 반드시 실패한다.** + +**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt) +``` +$ sudo -n -l +sudo: a password is required +$ sudo -n systemctl reload nginx +sudo: a password is required +``` + +**그러므로 이 네 줄은 자동화할 수 없다.** `ssh -t` 로 tty 를 붙여 사람이 +비밀번호를 친다. 이 실험이 처음에 강제 갱신을 못 하고 「미측정」으로 남긴 +이유가 정확히 이것이다. + +## 2-2. 먼저 `--dry-run` + +**하기** +```bash +ssh -t test-server 'sudo certbot renew --dry-run' +``` + +**어디를 봐야 하는가** — 끝의 `simulated renewals` 요약. 그리고 훅을 넣었다면 +`Running deploy-hook command` 줄(**미검증** — 이 실험대는 훅이 없는 상태에서 +쟀다). + +**이 결과가 의미하는 것** — dry-run 은 **인증서를 발급하지 않고 한도도 안 +깎는다.** 절차가 도는지, 검증이 통과하는지까지만 말해 준다. **파일이 실제로 +바뀌었을 때 nginx 가 그것을 집는지는 dry-run 으로 알 수 없다.** + +## 2-3. 강제 갱신 + +**되돌리기 — 없다.** 새 인증서는 되돌릴 수 없고, 한도를 한 장 깎는다. +1-8 의 감시 세 개가 돌고 있는지 다시 확인하고 친다. + +**하기** +```bash +date -u '+%H:%M:%S 갱신 시작 (dev)' +ssh -t test-server 'sudo certbot renew --force-renewal' +``` + +**어디를 봐야 하는가** — `Congratulations, all renewals succeeded:` 와 +그 아래 `fullchain.pem (success)`. + +**시각을 dev 시계로 적어 둔다.** 호스트가 찍는 시각은 106초 빠르다. + +--- + +# 3. 주입이 실제로 걸렸는지 확인한다 + +**「갱신 실패」와 「갱신은 됐는데 안 집었다」를 가르는 절이다.** 이 실험은 +처음에 이 둘을 구별하지 못해 두 갈래로 적어 뒀었다. + +## 3-1. 디스크에 새 파일이 써졌나 + +**하기** — 사람이 친다 +```bash +ssh -t test-server 'sudo certbot certificates' +``` +**실측** — [`12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt) +``` +Found the following certs: + Certificate Name: auth.hyeonworks.com + Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d + Key Type: ECDSA + Identifiers: auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com + Expiry Date: 2026-12-03 07:21:52+00:00 (VALID: 89 days) + Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem + Private Key Path: /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem +``` + +**어디를 봐야 하는가** — **`Serial Number` 와 `Expiry Date`.** +`6c7cb6df…ea1d` 는 1-1 에서 적어 둔 값과 **다르다.** 만료일도 하루 밀렸다 +(`Dec 2` → `Dec 3`). + +**이 결과가 의미하는 것** — **certbot 쪽에서는 갱신이 끝났다.** + +## 3-2. 파일이 언제 써졌나 + +**하기** — 사람이 친다 +```bash +ssh -t test-server 'sudo ls -la --time-style=full-iso /etc/letsencrypt/archive/auth.hyeonworks.com/' +``` +**실측** — [`12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt) +``` +-rw-r--r-- 1 root root 1359 2026-09-03 10:47:40.915923507 +0900 cert1.pem +-rw-r--r-- 1 root root 1359 2026-09-04 17:22:13.508494637 +0900 cert2.pem +-rw-r--r-- 1 root root 3523 2026-09-03 10:47:40.916215769 +0900 chain1.pem +-rw-r--r-- 1 root root 3523 2026-09-04 17:22:13.508658811 +0900 chain2.pem +-rw-r--r-- 1 root root 4882 2026-09-03 10:47:40.916339551 +0900 fullchain1.pem +-rw-r--r-- 1 root root 4882 2026-09-04 17:22:13.508821612 +0900 fullchain2.pem +-rw------- 1 root root 241 2026-09-03 10:47:40.916079294 +0900 privkey1.pem +-rw------- 1 root root 241 2026-09-04 17:22:13.507717972 +0900 privkey2.pem +``` + +**어디를 봐야 하는가** — 번호가 **1 과 2 두 벌**이라는 것, 그리고 2 번들의 +**mtime `2026-09-04 17:22:13`**. + +**★ 이 시각은 `(ts)` 다.** test-server 시계이고 106초 빠르다. +실제로는 **`08:20:27 (실제)`** 이다. 4-5 에서 이 보정을 쓴다. + +> `privkey2.pem` 의 권한이 `-rw-------` 인 것도 본다. **개인키는 D-3 의 주제와 +> 같은 문제**를 안고 있다 — 파일 하나를 얻으면 끝이다. + +--- + +# 4. 효과를 관찰한다 + +## 4-1. ★ 그런데 밖에서는 아무것도 안 바뀌었다 + +**확인** +```bash +tail -3 /tmp/d4-serial.txt +``` +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` + serial=0520BB6416D569E26697B1691440F523B853 + notBefore=Sep 3 00:47:23 2026 GMT ← 어제 것 그대로 + notAfter=Dec 2 00:47:22 2026 GMT + +일련번호 감시 161표본(약 13분) 동안 단 한 번도 바뀌지 않았다. +``` + +**어디를 봐야 하는가** — 일련번호가 **1-1 에서 적어 둔 값 그대로**인가. +디스크(3-1)의 `6c7cb6df…` 와 **다르다.** + +**이 결과가 의미하는 것** + +``` + 디스크 새 인증서 (6c7cb6df…) + 네트워크 옛 인증서 (0520BB…) +``` + +**두 사건이 갈라졌다.** 여기서 「갱신이 실패했다」고 결론 내리면 틀린다 — +3-1 에서 성공을 이미 봤다. + +## 4-2. nginx 워커가 그대로다 + +**확인** — 1-6 과 **똑같은 명령** +```bash +ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" +``` +**실측** +``` + 585 1 80529 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx + 586 585 80529 Thu Sep 3 19:00:39 2026 nginx: worker process +``` + +**어디를 봐야 하는가** — **워커 PID 586 이 그대로다.** `etimes` 도 계속 늘고 +있을 뿐 리셋되지 않았다. + +**이 결과가 의미하는 것** — **reload 가 없었다.** 그리고 1-6 에서 정한 판정 +기준이 여기서 답을 낸다 — 로그를 뒤질 필요가 없다. + +## 4-3. 개념 — 왜 파일이 바뀌어도 nginx 는 모르는가 + +**무엇인가.** nginx 는 `ssl_certificate` 가 가리키는 파일을 **기동 시점에 한 번 +읽어 메모리에 들고 있다.** 요청마다 디스크를 다시 보지 않는다. + +**확인** — nginx 가 무엇을 물고 있는지 본다(sudo 불필요) +```bash +ssh test-server 'grep -rn ssl_certificate /etc/nginx/' +``` +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` +/etc/nginx/sites-available/keycloak-lab:18: ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem; +/etc/nginx/sites-available/keycloak-lab:19: ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem; +``` + +**왜 여기 나오나.** `live/` 는 **심볼릭 링크**다. certbot 은 갱신하면 이 링크가 +새 `archive/` 파일을 가리키도록 바꾼다. + +``` + /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem + └─▶ (전) ../../archive/auth.hyeonworks.com/fullchain1.pem + └─▶ (후) ../../archive/auth.hyeonworks.com/fullchain2.pem +``` + +**경로는 그대로인데 내용만 바뀐다.** 그래서 nginx 설정을 고칠 필요가 없고, +**바로 그 때문에 「설정이 그대로니 괜찮다」고 착각하기 쉽다.** +필요한 것은 설정 변경이 아니라 **reload** 다. + +**없거나 틀리면.** 인증서가 만료되어 브라우저가 `NET::ERR_CERT_DATE_INVALID` +를 띄운다. **그 시점에 디스크에는 멀쩡한 인증서가 들어 있고 갱신 로그도 +`SUCCESS`** 다 — 그래서 원인을 찾는 데 오래 걸린다. + +## 4-4. 원인은 하나가 아니라 셋이 겹쳤다 + +1-5 에서 이미 본 표가 여기서 판정이 된다. + +| # | 경로 | 상태 | +|---|---|---| +| 1 | `certbot-renew.service` 의 `ExecStartPost` | **없다** | +| 2 | `renewal-hooks/{deploy,post,pre}/` | **셋 다 비었다** | +| 3 | certbot 의 nginx 플러그인 | **없다** | + +**세 경로 전부가 비어 있다. 하나라도 있었으면 자동으로 반영됐다.** + +## 4-5. ★ 공백을 계산한다 — 여기가 이 실험이 한 번 틀린 자리다 + +**하기** — 감시에서 언제 바뀌었는지 찾는다(5절에서 사람이 reload 한 뒤) +```bash +grep -v '0520BB' /tmp/d4-serial.txt | head +``` +**실측** — [`09-serial-timeline.txt`](../../evidence/d4-certificate-renewal/09-serial-timeline.txt) · +[`13-verdict.txt`](../../evidence/d4-certificate-renewal/13-verdict.txt) +``` + 08:10:51 ~ 08:58:47 serial=0520BB...B853 notAfter=Dec 2 ← 옛 것 + 08:58:52 serial=06C7CB...EA1D notAfter=Dec 3 ← 바뀐 순간 + + 08:22:13 ~ 08:58:52 구간에서 옛 인증서로 관측된 횟수: 428회 +``` + +**두 시각을 나란히 놓는다. 그런데 시계가 다르다.** + +| | 시각 | 어느 시계 | +|---|---|---| +| 새 인증서 디스크 기록 | `17:22:13 KST` → `08:22:13 UTC` | **(ts)** — 106초 빠르다 | +| 실제 서빙 시작 | `08:58:52` | **(dev)** — 정확 | + +**틀린 계산** — 그대로 빼면 +``` + 08:58:52 − 08:22:13 = 2199초 (36분 39초) ✘ +``` + +**맞는 계산** — 디스크 기록 시각을 실제 시각으로 보정한 뒤 뺀다 +``` + 디스크 기록 : 08:22:13 (ts) − 106초 = 08:20:27 (실제) + 서빙 시작 : 08:58:52 (dev) = 08:58:52 (실제) + ──────────────────────────────────────────── + 공백 : 2305초 = 38분 25초 ✔ +``` + +**어디를 봐야 하는가** — **106초는 두 값의 차이(2199)에 비하면 5% 도 안 된다.** +그래서 D-4 에서는 결론이 안 바뀌었다. **하지만 [D-4a](d4a-deploy-hook.md) 는 +1~2초를 재는 실험이고, 거기서는 같은 106초가 결과를 완전히 뒤집는다** — +보정하지 않으면 훅이 인증서 발급보다 104초 **먼저** 실행된 것이 되어 물리적으로 +불가능해진다. + +> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라 방향까지 +> 틀릴 수 있다.** 음수 지연이 나오면 계산이 아니라 시계를 의심한다. + +**그리고 이 38분은 우연히 짧았을 뿐이다.** reload 를 시킨 것은 **사람**이지 +자동화가 아니다. 아무도 안 했다면 **다음 nginx 재시작까지 — 즉 무기한 —** +옛 인증서를 서빙했을 것이다. + +## 4-6. 왜 88일 동안 안 보이나 + +``` + 오늘 타이머 두 번 SUCCESS (갱신할 것이 없으므로 아무 일도 안 한다) + +58일쯤 만료 30일 전 → 실제 갱신 ← 여기서 처음으로 절차가 시험된다 + +88일 만료 ← 증상이 나타나는 날 +``` + +**발현하는 날의 증상은 「인증서 만료」이고, 그날에도 로그는 `SUCCESS` 다.** +그래서 이 결함은 로그 감시로는 못 잡는다. **잡으려면 밖에서 `notAfter` 를 +재야 한다.** + +**확인** — 감시로 쓸 만한 한 줄. **미검증** +```bash +echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ + | openssl x509 -noout -checkend 2592000 +``` + +**어디를 봐야 하는가** — `Certificate will not expire` 인가 +`Certificate will expire` 인가. `2592000` 은 30일(초)이다. **서버에 로그인하지 +않고, 밖에서, 실제로 서빙 중인 것을 본다** — 이 세 가지가 이 실험의 교훈이다. + +## 4-7. 답 ② — reload 는 무중단인가 (근거) + +**5절에서 사람이 reload 한 뒤에 판정한다.** 감시 세 개를 멈추고 센다. + +**하기** +```bash +touch /tmp/d4-stop +grep -vE ' 200 ' /tmp/d4-poll.txt | head +grep -v '코드=200' /tmp/d4-inflight.txt | head +``` + +**실측** — [`13-verdict.txt`](../../evidence/d4-certificate-renewal/13-verdict.txt) + +**새 연결** — 0.2초 폴링, 08:10:51 ~ 09:02 +``` + 전체 표본 8856건 / 비200 **0건** + + 응답시간 n 중앙 p95 최대 + ───────────────────────────────────────────────────────── + 장기 평시 08:20~08:50 5398 98.0ms 205.7ms 1942.9ms + reload 직전 2분56초 489 116.0ms 200.8ms 387.7ms + reload 직후 2분08초 342 132.5ms 204.3ms 475.0ms +``` + +**어디를 봐야 하는가** — **p95 가 205.7 → 204.3 으로 사실상 동일**하고 최대값은 +오히려 낮다. 10초 구간 중앙값은 reload 전후 모두 80~190ms 사이를 오간다 — +**WiFi 잡음이지 reload 의 흔적이 아니다.** + +**진행 중이던 요청** — 계획서가 정확히 물은 지점 +``` +08:58:40 요청 시작 (845KB @ 20k/s) +08:58:52 ← nginx -s reload. 요청 시작 12초 뒤, 전송 한가운데 +08:59:21 종료: 코드=200 바이트=845361(전량) 연결수=1 curl종료=0 +``` + +| 관측 | 읽는 법 | +|---|---| +| 바이트가 전량이다 | 잘리지 않았다 | +| **연결수가 1이다** | 중간에 끊겨 재연결한 게 아니다 | +| 코드 200 | **옛 워커가 이 요청을 끝까지 책임졌다** | + +**이 결과가 의미하는 것** — **reload 는 무중단이다.** 옛 인증서로 시작한 연결이 +새 워커 전환을 **관통해** 끝까지 갔다. in-flight 전체 50건 중 종료코드 ≠ 0 은 +0건이다. + +### ★ 측정 장치가 거짓말할 뻔했다 + +in-flight 감시에서 **76건이 실패했다.** 그대로 적었으면 「갱신 중 대규모 요청 +실패」라는 오보가 됐을 것이다. **서버 탓이 아니었다.** + +**실측** — [`08-inflight-artifact.txt`](../../evidence/d4-certificate-renewal/08-inflight-artifact.txt) +``` + 08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0 ← 여기부터 + ... (76건, 전부 08:15:04) + 08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1 ← 곧바로 복귀 +``` + +| 근거 | 값 | +|---|---| +| 같은 순간 폴링 | 49건 **전부 200** | +| 연결수 | **0** — TCP 연결 시도조차 못 했다 | +| 소요 시간 | **50µs** — DNS 조회보다도 짧다 | +| 재현 | **0/100** | +| nginx | 그 시각에 아무 일도 안 했다(워커 22.4시간째) | + +**대조군이 오보를 막았다.** 그리고 **원인은 특정하지 못했다** — `curl` 을 `-s` +로 돌려 오류 메시지를 버렸고 종료 코드도 안 남겼기 때문이다. 1-8 의 +`d4-inflight.sh` 에 `curl종료=$E` 가 들어 있는 것이 그 수정이다. + +> **측정 장치가 실패했을 때 왜 실패했는지 남기지 않으면, 그 실패를 대상 탓으로 +> 돌릴지 장치 탓으로 돌릴지 판단할 근거가 없다.** + +--- + +# 5. 복구 — 사람이 reload 한다 + +**이 절이 곧 4-1 의 공백을 닫는 사건이다.** 순서상 관찰을 다 끝낸 뒤에 친다. + +## 5-1. reload + +**하기** — 사람이 친다 +```bash +date -u '+%H:%M:%S reload (dev)' +ssh -t test-server 'sudo nginx -t && sudo nginx -s reload' +``` + +**어디를 봐야 하는가** — `test is successful` 두 줄이 먼저 나오고, 그다음 +아무 말 없이 끝난다(`-s reload` 는 조용하다). + +### 개념 — 왜 `reload` 이고 `restart` 가 아닌가 + +**실측(호스트)** — 이 호스트의 `nginx.service` 유효 설정 +``` +Type=forking Restart=on-failure RestartUSec=100ms +StartLimitBurst=5 StartLimitIntervalUSec=10s +KillMode=mixed KillSignal=SIGQUIT PrivateTmp=true +``` + +**확인** — 이 값들은 유닛 파일이 아니라 **실제 적용값**이라 `show` 로 본다 +```bash +ssh test-server 'systemctl show nginx -p Type -p Restart -p RestartUSec \ + -p StartLimitBurst -p StartLimitIntervalUSec -p KillMode -p KillSignal -p PrivateTmp' +``` + +**이 값들이 왜 중요한가** + +| 설정 | 읽는 법 | +|---|---| +| `KillSignal=SIGQUIT` | 정지 신호가 nginx 의 **graceful shutdown** 신호다 — `stop` 도 연결을 끊지 않고 빠진다 | +| `Restart=on-failure` + `RestartUSec=100ms` | 죽으면 0.1초 뒤 다시 띄운다 | +| `StartLimitBurst=5` / `StartLimitIntervalUSec=10s` | **10초 안에 5번 실패하면 systemd 가 포기한다.** 설정이 깨진 채 `restart` 를 반복하면 **nginx 가 내려간 채로 멈춘다** | +| `PrivateTmp=true` | 이 서비스의 `/tmp` 은 **자기만의 것**이다. 여기 뭔가를 쓰면 밖에서 안 보인다 | + +**그래서 `nginx -t` 를 먼저 친다.** 설정이 깨진 상태에서 reload 를 보내면 +마스터가 새 워커를 못 띄우지만 **옛 워커는 그대로 서비스를 계속한다** — +인증서는 안 바뀌어도 서비스는 안 죽는다. `restart` 는 그 안전장치가 없다. + +> `systemctl reload nginx` 도 같은 일을 한다(유닛에 `ExecReload` 가 있을 때). +> 이 실험대에서 실제로 친 것은 `nginx -s reload` 이고, D-4a 의 훅도 그것을 +> 쓴다 — **미검증**인 쪽은 `systemctl reload` 다. + +## 5-2. 바뀌었나 — 두 곳을 본다 + +**확인 ①** 워커 PID +```bash +ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" +``` +**실측** — [`d4a-deploy-hook/01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt) +의 기준선. **D-4 에서 사람이 reload 한 결과가 이 워커다** +``` + 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 한 것 +``` + +**어디를 봐야 하는가** — **마스터 585 는 그대로, 워커는 586 → 28829.** +1-6 에서 세운 판정 기준 그대로다. + +**확인 ②** 밖에서 본 일련번호 +```bash +tail -3 /tmp/d4-serial.txt +``` +**실측** — [`09-serial-timeline.txt`](../../evidence/d4-certificate-renewal/09-serial-timeline.txt) +``` + 08:58:52 serial=06C7CB...EA1D notAfter=Dec 3 ← 바뀐 순간 +``` + +**어디를 봐야 하는가** — 일련번호가 **3-1 에서 본 디스크의 값과 같아졌는가.** + +**이 결과가 의미하는 것** — 디스크와 네트워크가 다시 일치한다. **그 사이의 +2305초가 이 실험의 답이다.** + +## 5-3. 원상복구 확인표 + +| 항목 | 명령 | 돌아왔을 때 | +|---|---|---| +| 서빙 인증서 | 1-1 의 `openssl … -serial` | **3-1 의 새 일련번호와 같다** | +| 체인 | 1-2 | 4단계, `Verify return code: 0` | +| 이름 셋 | 1-3 | 세 일련번호가 서로 같다 | +| nginx | `ps -eo pid,ppid,etimes,lstart,args \| grep nginx:` | 마스터 그대로, 워커 **새것** | +| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | +| 감시 | `ls /tmp/d4-stop` | 있어야 한다(멈춘 상태). 없으면 `touch` | +| 남은 프로세스 | `ps -ef \| grep d4-` | 없어야 한다 | +| 임시 파일 | `ls -l /tmp/d4-*.txt /tmp/inflight.bin` | 근거로 남기거나 지운다 | + +**인증서는 원상복구되지 않는다.** 새것이 정상이고, 옛것으로 돌아갈 이유도 없다. + +## 5-4. 진짜 고치는 법 + +**이 절차는 사람이 reload 를 쳤기 때문에 38분에서 끝났다.** 자동으로 되게 +하려면 훅이 필요하다. + +```bash +# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +#!/bin/sh +nginx -t && nginx -s reload +``` + +`deploy/` 는 **실제로 갱신된 인증서가 있을 때만** 실행된다. `post/` 는 갱신 +여부와 무관하게 매번 돌므로, 하루 두 번 쓸데없이 워커를 갈아치우게 된다. + +> **★ 이 처방은 검증됐다 — [D-4a](d4a-deploy-hook.md).** +> 훅 파일 하나로 **발급 → 서빙이 38분 25초에서 1~2초**가 됐다(약 1150배). +> **처방을 적고 시험하지 않는 것**이야말로 이 실험대가 계속 경계해 온 실수라서, +> 별도 실험으로 분리했다. **D-4 를 여기까지 했으면 D-4a 를 이어서 한다.** + +--- + +# 막히면 + +전부 이 실험대가 **실제로 겪은** 증상이다. + +| 증상 | 원인 | 확인 | +|---|---|---| +| 호스트에서 아무 명령이나 빈 결과 | **sudo 가 조용히 실패했다** | `sudo -n -l` → `a password is required`. `ssh -t` 로 다시 | +| `ssh test-server 'sudo …'` 가 멈춰 있다 | tty 가 없어 비밀번호를 못 묻는다 | **`ssh -t`** | +| 갱신했는데 일련번호가 그대로 | **그게 이 실험의 결과다** | 워커 PID 를 본다 — 4-2 | +| 워커 PID 로 판정이 안 선다 | 마스터까지 바뀌었다 | reload 가 아니라 **재시작**이다. `lstart` 를 본다 | +| 훅 디렉터리가 `Permission denied` | root 전용 | 「비었다」로 읽지 않는다 — 1-5 | +| 감시가 셸을 닫으면 죽는다 | `&` 만 붙였다 | **`setsid`** — 1-8 | +| in-flight 에 실패가 무더기로 | **로컬 아티팩트일 수 있다** | 같은 시각 폴링·`연결수`·소요 시간·재현 — 4-7 | +| 공백이 음수로 나온다 | **두 시계를 그대로 뺐다** | 1-7 로 돌아간다 | +| `notBefore` 로 발급 시각을 계산했다 | **LE 는 정확히 한 시간 백데이트한다** | SCT 를 본다 — 1-1 | +| crt.sh 에 인증서가 안 나온다 | **색인이 진실의 부분집합이다** | SCT 는 인증서 안에 있다. `-ext ct_precert_scts` | +| nginx 에러 로그가 중간에 잘린다 | **한 항목이 2048바이트에서 잘린다**(`NGX_MAX_ERROR_STR`) | 저널 포맷을 바꿔도 안 늘어난다. **access 로그**를 본다 | +| 체인이 1단계 | `cert.pem` 을 썼다 | 03 의 `ssl_certificate` 한 줄 — 1-2 | +| 발급 한도에 걸렸다 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 — 2-2 | + +### crt.sh 에 관한 곁다리 — 실측 + +발급 사실은 Certificate Transparency 에 남으므로 sudo 없이 확인할 수 있을 것 +같았다. 실제로 서빙 중인 인증서에는 SCT 가 2개 박혀 있다. **그런데** + +**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) +``` + $ curl -s 'https://crt.sh/?q=auth.hyeonworks.com&output=json' + [] ← 0건 + $ curl -s 'https://crt.sh/?q=hyeonworks.com&output=json' + 13건, 최신 not_before=2026-08-11 ← auth 는 없다 +``` + +**인증서에 SCT 가 박혀 있다는 것과 crt.sh 가 그것을 색인했다는 것은 다르다.** +관측 도구가 진실의 부분집합만 본다는, A-2 의 `up` 지표와 같은 종류의 함정이다. + +--- + +# 다음 + +| 실험 | D-4 가 남긴 것 | +|---|---| +| [D-4a](d4a-deploy-hook.md) deploy 훅 | **처방이 듣는지 시험한다.** 38분 25초 → 1~2초 | +| [D-3](d3-secret-management.md) 비밀 관리 | **`privkey.pem` 도 비밀이다.** 파일 하나가 전부다 | +| [B-7](../../experiment-b7-cookie-secret-rotation.md) | SAN 이 세 개뿐이라 **네 번째 호스트명을 못 썼다** — 1-3 | +| 운영 | **감시는 로그가 아니라 밖에서 본 `notAfter` 로 한다** — 4-6 | +| 전부 | **두 기계의 시각을 나란히 놓기 전에 시계부터 잰다** — 1-7 | +| 전부 | **처방을 적었으면 시험한다.** 이 문서는 처방만 적고 끝냈다가 D-4a 를 따로 해야 했다 | diff --git a/docs/guides/experiments/d4a-deploy-hook.md b/docs/guides/experiments/d4a-deploy-hook.md new file mode 100644 index 0000000..c1682dc --- /dev/null +++ b/docs/guides/experiments/d4a-deploy-hook.md @@ -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초는 그 자체로 결론을 뒤집는다 |