feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다

기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 11:01:55 +09:00
co-authored by Claude Opus 5
parent d473609e0a
commit 2109f726fe
574 changed files with 159654 additions and 1551 deletions
@@ -0,0 +1,207 @@
---
kind: CASE
slug: 200ms-of-delay-became-22-seconds
title: 200 밀리초를 넣었더니 응답이 22.2 초가 됐다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택의-이유와-지킨-경계-a6
assets:
- key: a6-latency-multiplication
file: ../../../final/assets/a6-latency-multiplication/a6-latency-multiplication.svg
evidence:
- ../../../final/evidence/raw/a6-latency-injection__02-delay-injected.txt
- ../../../final/evidence/raw/a6-latency-injection__04-pool-under-load.txt
---
# 200 밀리초를 넣었더니 응답이 22.2 초가 됐다
데이터베이스 패킷에 200 밀리초를 더했더니 로그인 응답이 66 밀리초에서 1,872 밀리초가 됐고, 동시 20 건에서는 가장 느린 요청이 22.2 초에 끝났다. 왕복마다 지연이 더해지고 그 뒤에 커넥션 풀 대기가 한 번 더 곱한다. 커넥션 획득 대기는 최대 20,000 밀리초였고 readiness 프로브도 같은 줄에 서서 타임아웃됐다.
## 관계
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
여기서 관측한 것은 프로브가 타임아웃된 것까지다. 그 뒤에 노드가 어떻게 치워지는지는 그 기록이 다룬다.
- **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것**
거기서는 노드를 통째로 끊었고 여기서는 노드 사이를 느리게만 만들었다. 느리게 만든 쪽도 헬스체크를 무너뜨리는 데까지는 닿았다.
## 문제
이 실험대는 Keycloak 두 대 가운데 하나만 데이터베이스와 같은 노드에 있다. kc-lab-2 의 keycloak-0 은 노드 안에서 PostgreSQL 에 닿고, kc-lab-1 의 keycloak-1 은 노드 사이를 건넌다.
노드 사이가 느려지면 세션을 데이터베이스에 두는 구성이 얼마나 느려지는지, 그리고 느려지기만 하는지 아니면 장애가 되는지를 재야 했다. 그 전에 예측을 두 개 적었다. 응답이 넣은 지연만큼 늘 것이고, 동시 로그인이 몰리면 낙관적 락 충돌이 보일 것이라고.
## 결론
넣은 지연 : 200 밀리초
로그인 응답 : 66 밀리초에서 1,872 밀리초로 · 28 배
동시 20 건에서 가장 느린 응답 : 22.2 초
동시 20 건 전부 200 : o
커넥션 획득 대기 최대 : 20,000 밀리초
readiness 프로브 : 타임아웃으로 실패한 이벤트가 찍혔다
파드 재시작 : 0 회. 서비스에서 빠졌는지는 재지 않았다
낙관적 락 충돌 : 0 건
28 배는 한 번에 생기지 않는다. 로그인 한 번이 데이터베이스를 여러 번 왕복하므로 200 밀리초가 왕복 횟수만큼 더해진다. 22.2 초는 거기서 한 단계 더 간 값이다. 길어진 요청이 커넥션을 붙들고 있는 동안 뒤의 요청이 풀에서 기다리고, 그 대기가 응답 시간에 더해진다.
느림은 헬스체크까지 닿았다. readiness 프로브가 정해진 시간 안에 답을 못 받아 실패 이벤트가 찍혔다. 그 다음에 오는 「노드가 서비스에서 빠진다」는 쿠버네티스가 그렇게 하도록 되어 있는 동작이지 이 실험에서 확인한 것이 아니다.
예측 두 개 가운데 하나는 틀렸다. 낙관적 락 충돌은 0 건이었다. 로그인은 세션 행을 INSERT 하지 UPDATE 하지 않아서 경합할 대상이 없다.
## 검증 환경
클러스터 : k3s · 노드 둘
keycloak-0 : kc-lab-2 · PostgreSQL 과 같은 노드
keycloak-1 : kc-lab-1 · 노드 사이를 건넌다
커넥션 풀 : agroal · Quarkus 의 JDBC 풀이라 지표 이름이 agroal 로 시작한다
지연을 건 대상 : PostgreSQL 이 보내는 패킷
지연을 건 인터페이스 : flannel.1 · VXLAN 캡슐화 전이라 파드 주소가 보인다
주입 값 : 200 밀리초 한 점
측정일 : 2026-09-04 13:1013:35 KST
## 재현 조건
1. 데이터베이스와 같은 노드에 있는 Keycloak 과 다른 노드에 있는 Keycloak 을 함께 둔다.
한쪽만 느려져야 그 차이를 지연 탓으로 돌릴 수 있다.
2. 주입 전에 두 노드에서 로그인을 20 회씩 걸어 평균 응답 시간을 적어 둔다.
3. 데이터베이스가 보내는 패킷에만 200 밀리초를 더한다.
물리 인터페이스에 걸면 안 된다. flannel VXLAN 이 이미 캡슐화해서 파드 주소가 헤더에 없다. flannel.1 에 건다.
4. 주입이 실제로 걸렸는지 결과와 따로 확인한다.
큐 규칙에 패킷이 잡혔는지 보고, 두 노드의 로그인 응답 시간이 갈라졌는지 본다. 갈라지지 않았으면 주입이 안 걸린 것이다.
5. 노드를 건너는 쪽으로 로그인 한 건을 보내 응답 시간을 잰다.
6. 같은 노드로 동시 20 건을 보내고 20 건 전부의 상태 코드와 응답 시간을 받는다.
일회성 파드로 띄우면 출력이 유실된다. 상주 탐침에서 파일로 모은다.
7. 부하 직후 커넥션 풀 지표를 읽는다.
획득 대기 최대 : agroal_blocking_time_max_milliseconds
최대로 쓴 커넥션 수 : agroal_max_used_count
8. 파드 이벤트에서 readiness 프로브가 실패했는지 확인한다.
이벤트는 한 시간 전 것까지 섞여 있으므로 Age 를 먼저 보고 이번 주입의 것만 고른다.
서비스에서 빠졌는지까지 보려면 엔드포인트 목록을 부하 중에 따로 읽어야 한다.
9. 지연을 풀고 두 노드의 응답 시간이 돌아오는지 본다.
## 본문
<!-- body:start -->
## 한쪽만 노드를 건넌다
Keycloak 은 두 대다. kc-lab-2 의 `keycloak-0` 은 PostgreSQL 과 같은 노드에 있어 노드 안에서 데이터베이스에 닿고, kc-lab-1 의 `keycloak-1` 은 노드 사이를 건넌다. 데이터베이스가 보내는 패킷에만 지연을 걸면 `keycloak-1` 만 느려지고 `keycloak-0` 은 그대로이므로, 두 값의 차이를 지연 탓으로 돌릴 수 있다.
주입 전에 `keycloak-1` 의 로그인 응답은 66 밀리초였다.
## 처음 건 지연은 걸리지 않았다
지연은 리눅스 트래픽 제어로 걸었다. 큐 규칙을 밴드로 나누고 출발지 주소가 PostgreSQL 인 패킷만 지연 밴드로 보내는 방식인데, 처음 지정한 인터페이스는 인터넷 예제가 전부 쓰는 `eth0` 이었고 명령이 장치를 찾지 못했다.
명령을 한 줄씩 치지 않고 스크립트로 묶어 돌린 탓에 그 실패가 그대로 지나갔다. `tc` 는 네 번 다 실패했는데 스크립트는 그 사이에 자기가 찍는 `적용완료` 를 끼워 넣고 주입 시각 `13:14:55` 까지 남긴 뒤 다음 절로 넘어갔다.
```text label="주입 직후 두 노드의 로그인 응답"
keycloak-0 평균 43 ms 최대 64 ms
keycloak-1 평균 47 ms 최대 70 ms
```
두 노드가 갈리지 않았다. 이 결과는 「지연을 넣어도 영향이 없다」와 구별되지 않는다. 이 실험대에서 주입은 아홉 번 조용히 실패했고, 그래서 주입한 다음 대상이 실제로 그 상태인지를 결과와 따로 확인하는 단계를 모든 실험에 두었다.
걸리지 않은 이유가 둘이었다. 하나는 배포판 차이로, Debian 게스트의 인터페이스 이름이 `eth0` 이 아니라 `enp1s0` 이다. 다른 하나는 오버레이 네트워크다. flannel 은 VXLAN(Virtual Extensible LAN) 으로 파드 사이 통신을 UDP 로 감싸 노드 사이를 건네므로 물리 인터페이스에서 보면 노드 주소 사이의 UDP 패킷이고 안쪽 파드 주소는 캡슐 안에 있다. 출발지가 PostgreSQL 인 패킷을 고르는 필터는 문법상 유효한 채로 영원히 0 건을 잡는다. 캡슐화 전인 `flannel.1` 에서 걸어야 안쪽 주소가 보인다.
둘째 이유는 `enp1s0` 로 이름만 고쳤을 때 벌어졌을 일이고, 캡슐화 구조에서 나온 결론이다. 원 실행은 `eth0` 이 실패한 뒤 곧바로 `flannel.1` 로 갔으므로, `enp1s0` 에서 필터가 0 건을 잡는 것을 본 출력은 이 실험에 없다.
## 로그인 한 건이 28 배가 됐다
`flannel.1` 에 다시 걸고 나서 `keycloak-1` 의 로그인 응답이 66 밀리초에서 1,872 밀리초가 됐다. 넣은 값은 200 밀리초인데 응답은 28 배다.
로그인 한 번이 데이터베이스 왕복 한 번으로 끝나지 않기 때문이다. 왕복마다 200 밀리초가 붙고 그 합이 응답 시간이 되므로, 66 밀리초가 1,872 밀리초가 된 것은 그 왕복이 여러 번이었다는 뜻이다. 로그인 하나가 왕복을 정확히 몇 번 하는지는 이 실험에서 세지 않았다.
같은 비교에 대조군도 함께 찍혀 있었다. 지연을 걸지 않은 `keycloak-0` 은 주입 전 70 밀리초에서 41 밀리초로 내려갔는데, 처음 적을 때는 그것을 「영향 없음」이라고 적었다. −41% 움직인 대조군은 영향 없음이 아니다. 그 −41% 자체는 주입과 무관한 변동이고 `JIT` 워밍업과 캐시가 그만큼을 움직였다. 28 배와 자릿수가 달라 결론은 그대로 서는데, 판정을 자릿수로 한 것이지 대조군이 안 변해서가 아니다. 이 실험대에서 대조군 없이 귀속하지 않는다는 규칙을 어긴 곳이 둘인데 그중 하나가 여기이고, 나중에 고쳤다.
## 동시 20 건에서 22.2 초
같은 노드로 동시에 20 건을 보냈다. 20 건 전부 `200` 을 받았는데 응답 시간이 1.9 초에서 22.2 초까지 벌어졌다.
| 동시 20 건 중 어느 요청인가 | 로그인 응답 시간 |
|---|---|
| 먼저 커넥션을 잡은 네 건 | 1.911 · 1.913 · 1.958 · 1.981 초 |
| 그 뒤 열네 건 | 3.441 초부터 21.905 초까지 약 1.4 초 간격 |
| 마지막 두 건 | 22.228 · 22.230 초 |
앞의 네 건은 앞 절의 1,872 밀리초와 같은 크기다. 나머지 열여섯 건은 커넥션이 비기를 기다렸고, 기다린 시간이 응답 시간에 그대로 더해졌다. 약 1.4 초 간격으로 한 건씩 빠져나오는 계단 모양이 그 대기다.
Keycloak 이 쓰는 커넥션 풀은 Quarkus 의 JDBC 풀인 Agroal 이라 지표 이름이 `agroal_` 로 시작한다. 부하가 끝나자마자 상주 탐침에서 `keycloak-1` 의 지표를 읽었다.
```bash label="부하 직후 커넥션 풀 지표"
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)"'
```
그 명령이 낸 값은 이렇다.
```text label="부하 직후 커넥션 풀"
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
```
커넥션을 받으려고 가장 오래 기다린 요청은 20,000 밀리초를 기다렸고, 최대로 쓴 커넥션은 19 개, 평균 대기는 281 밀리초였다. `active_count` 와 `awaiting_count` 가 0 인 것은 두 지표가 순간값이기 때문이다. 각각 지금 쓰이는 커넥션 수와 지금 줄 선 요청 수를 세므로, 부하가 끝난 뒤에 읽으면 0 이 나온다. `blocking_time_max` 는 누적이라 나중에 읽어도 20,000 이 남아 있다. 그 사이가 어떤 모양이었는지는 이 기록이 대지 못한다. 관측 스택에 히스토그램 지표가 없어서 281 밀리초와 20,000 밀리초 사이의 분포가 안 남았다.
주입하지 않은 상태에서 동시 20 건을 걸어 같은 지표를 읽은 값은 이 실험에 없다. 주입 전 측정은 로그인을 한 건씩 차례로 20 회 보낸 것이라 커넥션을 두고 다투는 요청이 없었다.
![네트워크 지연이 왕복 횟수만큼 누적되고 커넥션 풀 대기에서 다시 증폭되며 마지막에 readiness 실패로 이어지는 구성](../../../final/assets/a6-latency-multiplication/a6-latency-multiplication.svg)
넣은 지연과 22.2 초 사이에는 단계가 둘이다. 왕복마다 더해지는 것과 풀에서 기다리는 것 중 하나만 보면 28 배도 22.2 초도 계산되지 않는다.
## 헬스체크도 같은 줄에 섰다
부하를 건 뒤 파드 상태와 이벤트 목록을 함께 읽었다.
```text label="부하 직후 파드와 이벤트"
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` 짜리 두 줄은 이번 주입이 아니라 앞서 노드를 껐다 켠 실험이 남긴 것이고, 파드의 `RESTARTS` 가 `1 (51m ago)` 인 것도 같은 흔적이다. 이벤트 목록에는 한 시간 전 것까지 남으므로 `Age` 를 먼저 보고 이번 주입의 것만 고른다.
실패한 방식도 둘이 다르다. `32m` 짜리는 상태 코드 `503` 이라 Keycloak 이 답은 하면서 스스로 DOWN 이라고 말한 것이고, `89s` 짜리는 `context deadline exceeded` 로 답 자체를 못 한 것이다.
readiness 가 계속 실패하면 쿠버네티스가 그 파드를 서비스 엔드포인트에서 빼고, 그러면 밖에서는 느린 노드가 아니라 노드 하나가 사라진 것으로 보인다. 다만 그것은 쿠버네티스 문서가 정한 동작이지 이 실험이 확인한 것이 아니다. 엔드포인트 목록은 읽지 않았고, 파드는 지연을 풀자 재시작 없이 바로 돌아왔다.
## 예측 하나가 빗나갔다
주입 전에 적어 둔 예측 가운데 낙관적 락 충돌은 나오지 않았다. 계획서에 적힌 줄은 이랬다.
> **낙관적 락 충돌 증가** — 트랜잭션이 길어져 `VERSION` 충돌이 늘어야 한다
지연을 거는 동안 관련 로그는 0 줄이었다.
낙관적 락은 행을 잠그지 않고 읽은 뒤 갱신할 때 버전 값이 그대로인지 확인하는 방식이라, 같은 행을 여러 요청이 고칠 때 충돌이 난다. 그런데 로그인은 세션 행을 INSERT 하지 UPDATE 하지 않는다. 같은 행을 고치는 요청이 없으므로 확인할 버전도 충돌할 대상도 없다. 예측이 빗나간 이유는 락 구현이 아니라 연산의 종류에 있었다. 충돌이 0 건이라는 결과가 남은 것은 예측을 먼저 적어 두었기 때문이다.
## 이번에 재지 않은 것
지연을 200 밀리초 한 점에서만 걸었다. 50 밀리초나 500 밀리초에서 응답이 어떻게 되는지, 넣은 값이 왕복 횟수만큼 더해지는 것이 다른 값에서도 그대로인지는 재지 않았다.
어느 지연부터 readiness 가 실패하는지도 재지 않았다. 200 밀리초에서 실패한 것은 봤지만 그 아래 어디가 경계인지 모르므로, 이 기록은 실패하는 값 하나만 대고 실패하기 시작하는 값은 대지 못한다.
프로브가 실패한 뒤 파드가 서비스 엔드포인트에서 실제로 빠졌는지도 재지 않았다. 엔드포인트 목록을 읽는 명령은 원상복구 확인표에 들어 있지만, 부하 중에도 회복 뒤에도 그 출력이 남지 않았다.
동시 요청 수도 20 건 한 점이다. 커넥션 풀에서 최대로 쓴 커넥션이 19 개였으니 풀 상한 근처였을 수 있는데, 풀 크기를 바꿔 가며 22.2 초가 어떻게 움직이는지는 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,177 @@
---
kind: CASE
slug: four-logins-that-returned-200-and-vanished
title: 200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택의-이유와-지킨-경계-a2-a3
assets:
- key: a3-commit-to-disk-gap
file: ../../../final/assets/a3-commit-to-disk-gap/a3-commit-to-disk-gap.svg
evidence:
- ../../../final/evidence/raw/a3-database-crash__02-design-check.txt
- ../../../final/evidence/raw/a3-database-crash__03-loss-measurement.txt
- ../../../final/evidence/raw/a3-database-crash__07-loss-result.txt
- ../../../final/evidence/raw/a3-database-crash__08-wal-settings.txt
---
# 200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다
PostgreSQL 을 크래시시키고 한 건씩 대조했더니, 200 과 토큰을 받은 로그인 153 건 가운데 149 건만 세션 테이블에 남아 있었다. 네 건은 사용자 쪽에 토큰이 있는데 서버에 세션이 없다. Keycloak 이 세션 트랜잭션마다 synchronous_commit 을 꺼서 COMMIT 이 WAL 디스크 기록을 기다리지 않기 때문이다.
## 관계
- **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것**
그 실험은 데이터베이스를 노드째 잃고, 이 실험은 데이터베이스 프로세스만 끊는다. 잃는 범위가 달라서 잃는 것도 다르다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
데이터베이스가 없는 동안 새 로그인은 500 이었는데 up 지표는 1 이었다. 밖에서 재면 이 장애가 안 보이는 이유를 그 기록이 설명한다.
- **장애 시간의 대부분은 알아채는 데 걸린다**
그 기준은 사건과 사건을 알아채는 시점 사이가 장애 시간을 정한다고 말한다. 여기서는 성공 응답과 디스크 기록 사이가 잃는 양을 정했다.
## 문제
Keycloak 26 은 persistent-user-sessions 가 기본값이라 사용자 세션을 데이터베이스에 쓴다. 그래서 데이터베이스를 잃을 때 무엇까지 잃는지가 세션의 내구성을 정한다.
데이터베이스를 멈춘 실험(A-2)에서는 새 로그인이 500 을 받는 동안에도 up 지표가 1 이었다. 데이터베이스를 죽인 실험(A-3)에서 물은 것은 하나다. RPO(Recovery Point Objective, 복구 시점 목표)가 0 인가, 0 이 아니면 몇 건인가.
## 결론
RPO 는 0 이 아니었다.
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
그중 데이터베이스에 존재 : 149 건
유실 : 4 건
유실한 세션의 토큰을 클라이언트가 들고 있다 : o
원인은 커밋 설정에 있다. Keycloak 은 세션을 쓰는 트랜잭션마다 COMMIT 앞에서 SET LOCAL synchronous_commit TO OFF 를 건다. 전역 설정은 on 이지만 이 트랜잭션에서만 off 가 되고, COMMIT 은 WAL 이 디스크에 닿기 전에 반환한다.
잃을 수 있는 양의 상한은 WAL writer 가 깨어나는 주기가 정한다. 그 값을 재 보니 기본값 200ms 였다.
이것은 세션 쓰기를 빠르게 하려고 고른 설계이고 고장이 아니다. 이 측정은 그 설계로 무엇을 내주는지를 숫자로 닫았다. 초당 14 건으로 로그인이 들어오는 동안 데이터베이스가 죽으면 네 건이 사라진다.
## 검증 환경
Keycloak : 26 · persistent-user-sessions 기본값
데이터베이스 : PostgreSQL · 전역 synchronous_commit on
세션 트랜잭션의 synchronous_commit : off · SET LOCAL 로 트랜잭션마다
wal_writer_delay : 200ms · 기본값 · 실측
로그인율 : 초당 14 건
측정일 : 2026-09-04 · 재기동 로그의 날짜다
실험대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. 세션 행에서 시간 값을 읽어 유실을 재려고 하지 않는다.
LAST_SESSION_REFRESH 가 초 단위 integer 라 200 밀리초짜리 유실은 값에 나타나지 않는다. 행이 있는지 없는지로 가른다.
2. 세션 테이블을 비우고 시작한다.
3. 로그인을 빠르게 반복하면서 성공한 응답의 세션 식별자를 파일에 모은다.
로그인 루프는 데이터베이스가 죽어도 살아 있어야 하므로 호스트에서 띄운다.
4. 8 초쯤 지나 로그인이 100 건을 넘으면 PostgreSQL 을 크래시시킨다.
파드를 강제 삭제하거나 PID 1 에 SIGKILL 을 보내는 방법으로는 크래시가 나지 않는다.
5. 재기동 로그에 크래시 복구가 찍혔는지 먼저 확인한다.
not properly shut down 과 redo starts 가 없으면 크래시가 아니다.
ready to accept connections 줄이 있는지로 판정하지 않는다. 그 줄의 시각이 새로 찍혔는지를 본다 — 이전에 뜬 시각 그대로면 데이터베이스는 내려간 적이 없다.
6. 모아 둔 세션 식별자를 세션 테이블과 한 건씩 대조한다.
200 을 받은 건수와 테이블에 남은 건수의 차이가 유실이다.
7. 전역값과 트랜잭션 값이 다를 수 있으므로 문장 로깅으로 SET LOCAL 을 잡고, wal_writer_delay 는 직접 조회한다.
## 본문
<!-- body:start -->
## 무엇을 세어야 유실이 보이나
Keycloak 26 은 `persistent-user-sessions` 가 기본값이라 사용자 세션을 데이터베이스에 쓴다. 데이터베이스를 멈춘 실험(A-2)에서는 새 로그인이 `500` 이 됐고, 그동안에도 Prometheus 의 `up` 지표는 1 이었다. 죽인 실험(A-3)이 물은 것은 죽는 순간에 무엇까지 잃는가였다.
재는 방법부터 막혔다. 세션 갱신 시각이 되감기는지로 재려던 설계였는데, 세션 행의 `LAST_SESSION_REFRESH` 는 초 단위 integer 라 200 밀리초 안팎의 유실은 값에 나타나지 않는다. 그 설계를 버리고 시간 대신 행이 있는지 없는지로 갈랐다. 로그인 하나가 행 하나이므로 판정이 이진이 된다. 클라이언트가 200 과 토큰을 받은 로그인의 세션 식별자를 모아 두었다가 복구 뒤 테이블과 한 건씩 맞춘다.
로그인 루프는 데이터베이스가 죽어도 계속 돌아야 하므로 클러스터 밖 호스트에서 띄웠다.
## 크래시를 만드는 데 두 번 실패했다
죽이는 방법을 두 번 틀렸고, 두 번 다 유실이 0 건으로 나왔다. 잃지 않은 것이 아니라 죽인 적이 없는 것이었다.
| 무엇으로 죽였나 | 크래시 복구가 돌았나 |
|---|---|
| `kubectl delete --grace-period=0 --force` | x — 런타임이 SIGTERM 을 보내 PostgreSQL 이 정상 플러시했다 |
| `kill -9 1` | x — PID 1 은 자기 네임스페이스의 SIGKILL 을 무시한다 |
첫 번째 시도에서는 `12:00:26.511` 에 강제 삭제 명령을 보냈고 `12:00:26.586` 에 명령이 돌아왔다. 그런데 재기동한 PostgreSQL 이 남긴 줄은 `database system is ready to accept connections` 하나뿐이었다. `not properly shut down``redo` 도 없으니 재생할 WAL 이 없었다는 뜻이고, 데이터베이스는 깨끗하게 내려갔다 올라온 것이다.
두 번째 시도에서는 루프를 8 초 돌려 로그인이 110 건 쌓인 뒤 `12:03:21.441` 에 컨테이너 안에서 `kill -9 1` 을 보냈다. 최종 성공 로그인은 139 건이었다. 이번에는 데이터베이스가 내려가지도 않았다. 파드의 `RESTARTS` 가 오르지 않았고, 로그 마지막 줄의 시각은 `02:59:48` 로 첫 번째 시도 때 뜬 그 시각 그대로였다. 새로 찍힌 줄이 아니므로 재기동 자체가 없었다.
죽이지 못한 실험과 영향이 없는 실험은 결과가 똑같이 나오기 때문에, 유실을 세기 전에 크래시 복구가 돌았는지부터 확인하는 단계를 두었다. 이때 `ready to accept connections` 줄이 있는지로 판정하지 않고, 그 줄의 시각이 새로 찍혔는지를 본다. 이 단계를 미리 두지 않았다면 첫 시도의 「유실 0 건」을 그대로 결과로 적었을 것이고, 이 실험의 결론은 정반대가 됐을 것이다.
진짜 크래시가 난 시도에서는 재기동 로그에 재생 과정이 찍혔다.
```text label="크래시 복구가 돌았을 때만 나오는 줄"
database system was not properly shut down; automatic recovery in progress
redo starts at 0/...
```
WAL(Write-Ahead Logging, 미리 쓰는 로그)은 데이터 파일을 고치기 전에 변경 기록을 로그에 먼저 쓰는 방식이고, 크래시 뒤에는 그 로그를 재생해 복구한다. 위 두 줄이 그 재생이다.
## 153 건 중 149 건
크래시가 확인된 시도의 대조 결과다.
```text label="크래시 전후 대조"
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
그중 DB 에 실제로 존재 : 149 건
★ 유실 : 4 건
```
유실한 네 건의 세션 식별자도 증거 원문에 한 줄씩 적혀 있다. 사용자 쪽에는 토큰이 있고 서버 쪽에는 그 토큰이 가리킬 세션이 없다. 화면에서는 방금 로그인했는데 다시 로그인하라는 응답으로 나타난다.
## COMMIT 이 반환되고 나서도 디스크에는 아직 없다
이 측정을 시작하기 전에 답해야 할 것이 하나 있었다. 앞선 실험(A-0)에서 `SET LOCAL synchronous_commit TO OFF` 를 잡은 것은 refresh 트랜잭션이었고, 로그인 트랜잭션도 그런지는 확인한 적이 없었다. 로그인이 동기 커밋이면 로그인은 사라지지 않고 이 측정 설계 자체가 성립하지 않는다. 그래서 주입 전에 문장 로깅을 켜고 로그인 한 번을 보냈다.
```text label="문장 로깅에서 본 로그인 트랜잭션 — 칼럼 목록은 줄였다"
BEGIN
insert into OFFLINE_USER_SESSION (...) values (...)
insert into OFFLINE_CLIENT_SESSION (...) values (...)
SET LOCAL synchronous_commit TO OFF
COMMIT
```
COMMIT 바로 앞에 설정 한 줄이 들어 있다. 확인하고 나서 문장 로깅은 곧바로 껐다 — 켜 둔 채로 수백 건의 로그인을 도는 주입에 들어가면 로그가 폭주하고 크래시 타이밍 자체가 달라진다.
`synchronous_commit` 은 COMMIT 이 WAL 디스크 flush 를 기다린 뒤 반환할지 정한다. 서버 전역값은 `on` 인데 Keycloak 이 이 트랜잭션에만 `SET LOCAL` 로 `off` 를 걸기 때문에, COMMIT 은 flush 를 기다리지 않고 즉시 반환하고 클라이언트는 200 과 토큰을 받는다. 그러고 나서 WAL writer 프로세스가 깨어나 버퍼를 디스크로 내보낸다.
그 사이가 얼마나 벌어지는지는 WAL writer 가 깨어나는 주기인 `wal_writer_delay` 가 정한다. 처음 문서는 이 값을 재지 않고 적었다가 나중에 고쳤고, 실제로 조회하니 기본값 `200` 밀리초였다. `synchronous_commit` 은 전역이 `on`, `commit_delay` 는 `0`, `wal_writer_flush_after` 는 `128` 이었고 넷 다 기본값이었다. 기록을 증거와 하나씩 대조할 때 로그인율도 함께 걸렸다. 초당 19 건으로 적혀 있었는데 증거가 대는 값은 14 건이었다. 원 자료는 이 두 건을 고치면서 규칙을 하나 적어 두었다 — 가정한 값은 재기 전에 재 둔다. 결과를 본 뒤에 재면 「맞춰 보는」 것이 된다.
![클라이언트가 200 을 받은 뒤에도 WAL 이 아직 디스크에 닿지 않은 구간이 남아 있는 구성](../../../final/assets/a3-commit-to-disk-gap/a3-commit-to-disk-gap.svg)
그림의 화살표 셋 가운데 디스크로 가는 마지막 화살표만 지연된다. 앞의 둘은 클라이언트가 응답을 받기 전에 끝나 있다. 초당 14 건이면 200 밀리초 창에 들어오는 로그인은 계산으로 세 건 안팎이고, 실제로 잃은 것은 네 건이었다.
창 하나가 유실을 전부 정하지는 않는다. `wal_writer_flush_after` 와 체크포인트 타이밍이 같이 걸리고, 다시 돌리면 로그인 속도도 죽인 순간도 달라진다. 그래서 이 측정이 말할 수 있는 것은 「4」가 아니라 「0 이 아니다」와 「그 크기가 WAL 플러시 주기와 같은 자릿수다」까지다.
## 밖에서 보면 이 유실이 없다
응답을 받은 쪽에서는 실패가 아니다. 상태 코드는 200 이고 토큰도 정상이다. 데이터베이스가 완전히 멈춘 동안에도 `up` 지표는 1 이었으므로, 지표만 보는 감시로는 멈춘 것도 잃은 것도 잡히지 않는다.
그래서 이 실험이 남긴 수치는 세션 수가 아니라 응답과 기록의 차이다. 세션을 데이터베이스에 두기로 한 구성에서 「로그인이 성공했다」는 「세션이 남았다」와 같은 말이 아니고, 그 둘 사이의 폭을 정하는 것이 `wal_writer_delay` 다.
## 이번에 재지 않은 것
`synchronous_commit` 을 켠 대조군은 돌리지 않았다. 세션 트랜잭션에서 `SET LOCAL` 을 빼면 유실이 0 이 되는지, 그렇게 했을 때 로그인 응답이 얼마나 느려지는지는 재지 않았다. 데이터베이스 쪽에서 `synchronous_commit` 을 `on` 으로 걸어 두는 것으로는 그 대조군이 만들어지지 않는다 — 트랜잭션 안의 `SET LOCAL` 이 우선하므로 Keycloak 설정이나 소스를 건드려야 하고, RPO 0 이 필요하면 복제로 푸는 쪽이 맞다고 원 자료는 적는다. 그래서 이 기록은 지금 설계의 대가만 말하고, 바꿨을 때의 대가는 말하지 않는다.
유실 4 건도 초당 14 건이라는 이 로그인율에서 나온 값이다. 로그인율을 바꿔 가며 유실이 창 크기에 비례하는지는 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,152 @@
---
kind: CASE
slug: ghost-rows-are-cleaned-by-the-surviving-coordinator
title: StatefulSet 과 Deployment 가 같은 답을 냈고, 유령 행은 남은 코디네이터가 지웠다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-11
source:
- final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-무엇을-쟀나
- final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-관측
- final/document.md#2026-09-11-추가-측정-워크로드-종류가-클러스터에-미치는-영향-결론
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
assets:
- key: ghost-row-cleanup-order
file: ../../../final/assets/ghost-row-cleanup-order/ghost-row-cleanup-order.svg
---
# StatefulSet 과 Deployment 가 같은 답을 냈고, 유령 행은 남은 코디네이터가 지웠다
유령 행은 남지 않았다. 죽은 노드의 JGROUPS_PING 행은 남아 있는 코디네이터가 지웠다. StatefulSet 과 Deployment 에서 파드를 정상 종료와 강제 종료로 죽인 네 조합이 모두 같은 답을 냈고, experiment-plan.md 에 미해결로 적혀 있던 항목이 「자동」으로 닫혔다.
## 관계
- **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것**
그 실험은 노드 자체를 잃는 경우를 재고, 이 측정은 파드만 죽이는 두 가지를 쟀다. 코디네이터 자신이 죽는 경우는 양쪽 다 재지 않았다.
- **롤링 재시작은 세션을 남기고 캐시만 지웠다**
Deployment 의 교체 순서를 StatefulSet 에 맞추려고 롤링 정책을 명시했는데, 그 순차 교체를 실제로 걸어 본 것이 그 실험이다.
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
JGROUPS_PING 이 세션을 나르는 경로가 아니라 서로를 찾는 경로라는 구분이 이 측정의 전제다.
## 문제
노드가 클러스터를 떠나면 JGROUPS_PING 에서 그 노드의 행도 없어져야 한다. 그런데 experiment-plan.md 에는 그 행이 자동으로 정리되는지 사람이 지워야 하는지가 미해결로 적혀 있었다.
여기에 하나가 더 걸려 있었다. 이 실험대는 Keycloak 을 StatefulSet 으로 띄웠고, 그래서 노드 상실 실험과 롤링 재시작 실험의 절차를 keycloak-0 을 죽인다고 적을 수 있었다. 다만 StatefulSet 을 고른 근거가 클러스터 동작에 있는지 문서를 읽기 쉽게 하려는 것인지는 갈라 보지 않았다.
## 결론
네 조합 어디에서도 유령 행이 나오지 않았다. 워크로드 종류가 StatefulSet 이든 Deployment 이든, 종료가 정상 종료든 강제 종료든 같은 답이 나왔다.
정리 주체 : 떠나는 노드가 아니라 남아 있는 코디네이터
SIGKILL 로 죽인 경우에도 정리 : o
뷰 변경 시각과 행 소멸 시각 : 같은 초
experiment-plan.md 의 미해결 항목 : 「자동」으로 닫힘
StatefulSet 이라도 같은 행을 덮어쓰지 않는다. address 는 순번으로 매번 새로 발급되고 name 의 접미사도 바뀐다. 안정적인 것은 keycloak-0 이라는 접두사뿐이다.
그래서 StatefulSet 을 고른 근거는 둘로 좁혀진다. 로그 줄과 JGROUPS_PING 의 행을 접두사로 대조할 수 있다는 것, 그리고 노드 상실 실험과 롤링 재시작 실험의 절차를 keycloak-0 을 죽인다고 적을 수 있다는 것. 둘 다 사람이 읽고 지목하려고 쓰는 성질이고 클러스터가 다르게 동작해서 생긴 것이 아니다.
세션을 데이터베이스에 두고 롤링 정책을 명시하면 Deployment 로도 이 실험대를 돌릴 수 있다. 다만 이것은 이 측정에서 나온 추론이고, 프로젝트가 워크로드 종류를 바꾸기로 정한 기록은 없다.
## 검증 환경
Keycloak : 2노드 클러스터
워크로드 종류 : StatefulSet · Deployment
클러스터 디스커버리 : PostgreSQL 의 JGROUPS_PING 테이블
Deployment 롤링 정책 : strategy.rollingUpdate 의 maxSurge 0, maxUnavailable 1
실험대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. Keycloak 을 2노드로 띄우고 클러스터가 형성되는지 확인한다.
JGROUPS_PING 에 두 행이 있고 코디네이터가 선출돼야 한다.
2. 같은 구성을 StatefulSet 과 Deployment 두 형태로 각각 만든다.
Deployment 에는 strategy.rollingUpdate 의 maxSurge 를 0, maxUnavailable 을 1 로 적어 StatefulSet 의 순차 교체에 맞춘다.
3. 각 형태에서 파드 하나를 정상 종료한다.
kubectl delete pod <파드 이름>
4. 각 형태에서 파드 하나를 강제 종료한다.
kubectl delete pod <파드 이름> --grace-period=0 --force
5. 3 번과 4 번 직후마다 JGROUPS_PING 을 조회한다.
kubectl exec deploy/postgres -- psql -U keycloak -c 'select name, ip from jgroups_ping'
6. 남은 노드의 로그에서 새 클러스터 뷰가 찍힌 시각을 확인하고, 5 번에서 행이 없어진 시각과 견준다.
## 본문
<!-- body:start -->
## 워크로드 종류 둘과 종료 방식 둘
`JGROUPS_PING` 은 Keycloak 노드가 서로를 찾을 때 쓰는 PostgreSQL 테이블이다. 노드는 뜨면서 이 테이블에 자기 행을 넣고, 테이블에 있는 행들로 지금 클러스터에 누가 있는지(클러스터 뷰)를 만든다. 메시지가 실제로 오가는 경로는 TCP 7800 이라, 서로를 찾는 경로와 나르는 경로가 다르다. 노드가 죽었는데 그 행이 지워지지 않고 남으면 유령 행이라고 부른다.
이 측정은 실험 26건을 끝낸 뒤 재현 가이드를 다시 따라가다 돌렸다. `experiment-plan.md` 에는 「`JGROUPS_PING` 의 유령 행이 어떻게 정리되는가 — 자동인가 수동인가」가 미해결로 남아 있었다.
Keycloak 2노드를 StatefulSet 과 Deployment 두 형태로 각각 띄우고, 각 형태에서 파드 하나를 정상 종료(`kubectl delete pod`)와 강제 종료(`--grace-period=0 --force`)로 한 번씩 죽였다. 그리고 죽일 때마다 `JGROUPS_PING` 테이블을 조회했다. `keycloak-1` 은 k3s 컨트롤 플레인이 있는 kc-lab-1 에 떠 있고, `keycloak-0` 과 PostgreSQL 은 에이전트 쪽 kc-lab-2 에 있다.
```bash label="파드를 죽인 직후마다 돌린 조회"
kubectl exec deploy/postgres -- psql -U keycloak -c 'select name, ip from jgroups_ping'
```
두 형태를 그대로 견주면 교체 순서가 달라서 답이 갈릴 수 있다. 그래서 Deployment 의 `strategy.rollingUpdate` 에 `maxSurge` 를 `0`, `maxUnavailable` 을 `1` 로 명시했다. `maxSurge` 가 `0` 이면 정원을 넘겨 새 파드를 미리 띄우지 않고 `maxUnavailable` 이 `1` 이면 한 번에 한 파드만 빠지므로, 한 대를 내린 뒤 한 대를 올리는 StatefulSet 의 순차 교체와 같은 순서가 된다. 두 구성이 다른 것은 워크로드 종류뿐이다.
## 네 조합이 같은 답을 냈다
두 형태 모두 띄우자마자 `JGROUPS_PING` 에 행이 둘 생기고 그중 하나가 코디네이터로 뽑혔다. 정상 종료한 뒤 조회했더니 죽은 파드의 행은 사라지고 새로 뜬 파드의 행이 대신 들어와 있었다. `--grace-period=0 --force` 로 끊었을 때도 죽은 행은 남지 않았고, 복구가 끝난 뒤 뷰는 다시 2명이었다.
| 무엇을 봤나 | StatefulSet | Deployment |
|---|---|---|
| 클러스터 형성 | 2행, 코디네이터 선출 | 같음 |
| 정상 종료 후 | 죽은 행 사라짐, 새 행 생성 | 같음 |
| 강제 종료(SIGKILL) 후 | 유령 행 없음 | 같음 |
| 복구 후 뷰 | 2명 | 같음 |
| `address` 값 | `…0002 → …0003 → …0004` | `…0005 → …0007 → …0008` |
| `name` 값 | `keycloak-0-60375` | `keycloak-85469cb4d-cfzkt-24175` |
네 조합 어디에서도 유령 행이 나오지 않았다. 값이 갈린 것은 `address` 와 `name` 두 줄뿐이고, 그 차이는 뒤에서 다시 본다.
## 행이 없어진 시각과 뷰가 바뀐 시각
강제 종료한 쪽에서 남은 노드의 로그를 보면, 떠난 노드가 빠진 뷰와 새 노드가 들어온 뷰가 차례로 찍혀 있다.
```text label="남은 노드의 클러스터 뷰 변경"
07:40:28 ISPN000094: new cluster view ... |4] (1) [keycloak-1-36736]
07:40:28 ISPN100001: Node keycloak-0-60375 left the cluster
07:40:48 ISPN000094: new cluster view ... |5] (2) [keycloak-1-36736, keycloak-0-16105]
07:40:48 ISPN100000: Node keycloak-0-16105 joined the cluster
```
`07:40:28` 에 `keycloak-1-36736` 혼자인 뷰가 만들어지면서 `keycloak-0-60375` 가 빠졌고, 20초 뒤 `07:40:48` 에 `keycloak-0-16105` 가 들어와 다시 두 노드가 됐다. `JGROUPS_PING` 에서 행이 없어진 시각도 같은 초에 찍힌다. 행을 지운 쪽은 강제 종료로 끊긴 노드가 아니라 남아 있는 코디네이터다. 그래서 `experiment-plan.md` 의 미해결 항목은 「자동」으로 닫힌다. 같은 문서가 노드 자체를 잃는 실험의 예상 칸에는 `JGROUPS_PING` 을 「죽은 노드 행이 남아 있다 (지울 주체가 없다)」로 적어 두었는데, 그 「지울 주체」가 남은 코디네이터였다.
다만 이 답은 코디네이터가 살아남은 두 종료에서 나왔다. 정상 종료와 SIGKILL 만 쟀고, 코디네이터 자신이 죽으면 행을 지우는 쪽이 사라지는데 그 경우는 이번에 재지 않았다.
![강제 종료된 노드가 빠지고 남은 코디네이터가 JGROUPS_PING 의 행을 지운 뒤 다른 이름의 노드가 합류하는 순서](../../../final/assets/ghost-row-cleanup-order/ghost-row-cleanup-order.svg)
로그에 찍히는 것은 뷰가 바뀐 두 시각까지이고, 행이 어느 쪽에서 지워졌는지는 로그 줄에 없다. 위 그림에서 `JGROUPS_PING` 으로 들어가는 화살표는 3번 하나뿐이고 그 화살표는 `keycloak-1-36736` 에서 나온다. `keycloak-0-60375` 쪽에는 테이블로 가는 화살표가 없다 — `--force` 로 끊긴 뒤라 자기 행을 지우고 나갈 틈이 없었다. 4번에서 돌아온 노드는 `keycloak-0-16105` 라는 다른 이름인데, 이름이 왜 달라지는지는 다음 절에서 본다.
## StatefulSet 이 고정해 주는 것은 접두사까지다
StatefulSet 을 써도 같은 행을 덮어쓰지는 않았다. `address` 는 순번으로 매번 새로 발급되고 `name` 의 접미사도 바뀐다. 위 표의 `name` 을 보면 `keycloak-0-60375` 였던 값이 복구 뒤 `keycloak-0-16105` 가 됐고, 앞의 `keycloak-0` 만 그대로다.
Deployment 쪽 `name` 은 `keycloak-85469cb4d-cfzkt-24175` 라서 `keycloak` 뒤에 붙는 부분을 미리 알 수 없고, `keycloak-0` 같은 짧은 접두사로 노드를 지목할 수 없다.
StatefulSet 을 고른 근거는 그래서 둘로 남는다. 하나는 로그 줄과 `JGROUPS_PING` 의 행을 `keycloak-0` 이라는 접두사로 대조할 수 있다는 것이고, 다른 하나는 노드 상실 실험과 롤링 재시작 실험의 절차를 「`keycloak-0` 을 죽인다」로 적을 수 있다는 것이다. 둘 다 사람이 읽고 지목하려고 쓰는 성질이지 클러스터가 다르게 동작해서 생긴 것이 아니다. 세션을 데이터베이스에 두고 롤링 정책을 명시하면 Deployment 로도 같은 실험대를 돌릴 수 있다.
## 이번에 재지 않은 것
정상 종료와 강제 종료 두 가지만 걸었다. 노드 자체를 잃는 실험(A-4)처럼 노드가 통째로 사라지는 경우는 이번에 재지 않았고, 그때는 코디네이터 자신이 죽을 수 있어서 행을 지우는 쪽이 없어진다. 그 경우의 결과는 다를 수 있다.
Deployment 로도 실험대를 돌릴 수 있다는 판단은 이 네 조합에서 나온 추론이다. 프로젝트가 워크로드 종류를 StatefulSet 에서 Deployment 로 바꾸기로 정한 기록은 없다.
<!-- body:end -->
@@ -0,0 +1,197 @@
---
kind: CASE
slug: two-ways-to-lose-a-node
title: 노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택의-이유와-지킨-경계-a4
assets:
- key: lab-topology
file: ../../../final/assets/lab-topology/lab-topology.svg
- key: a4-two-node-losses
file: ../../../final/assets/a4-two-node-losses/a4-two-node-losses.svg
evidence:
- ../../../final/evidence/raw/a4-node-loss__02-worker-node-killed.txt
- ../../../final/evidence/raw/a4-node-loss__03-state-during-loss.txt
- ../../../final/evidence/raw/a4-node-loss__04-eviction-timing.txt
---
# 노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것
두 노드짜리 클러스터에서 노드를 하나씩 잃어 봤다. 워커를 끊었더니 외부 응답이 503 이 됐고, 컨트롤 플레인을 끊었더니 연결 자체가 되지 않아 000 이 됐다. 복구는 양쪽 다 가상머신을 다시 켜고 60 초였다.
## 관계
- **200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다**
거기서는 데이터베이스 프로세스만 끊었고 여기서는 데이터베이스가 노드째 사라진다. 잃는 범위가 달라서 복구 절차도 갈린다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
죽은 노드의 파드가 READY 로 남고 살아 있는 노드의 파드가 READY 에서 빠지는 역전이 이 실험에서 나왔다.
- **장애 시간의 대부분은 알아채는 데 걸린다**
그 기준의 근거가 이 실험의 타이머 두 개다. 감지가 끝나기 전에는 복구 절차가 시작되지도 않는다.
## 문제
이 실험대는 노드가 둘이고 그중 kc-lab-2 한 곳에 PostgreSQL 과 Redis 가 몰려 있다. 진입 경로인 traefik 과 k3s API 서버는 반대쪽 kc-lab-1 에 있다.
워크로드를 두 노드에 나눠 띄웠으니 노드 하나를 잃어도 서비스가 이어져야 한다. 그런데 어느 노드를 잃느냐에 따라 남는 것이 다르고, 그것을 재기 전에는 두 경우가 같은 장애로 보인다.
## 결론
노드 하나를 잃으면 양쪽 다 전면 장애였고 원인은 달랐다.
워커(kc-lab-2) 상실 : 외부 응답 503 · kubectl 정상 · 원인은 데이터베이스가 같이 죽은 것
컨트롤 플레인(kc-lab-1) 상실 : 외부 응답 000 · kubectl 불통 · 원인은 들어갈 길이 없는 것
복구 : 양쪽 다 가상머신을 다시 켜고 60 초
컨트롤 플레인을 잃었을 때 keycloak-0 은 계속 돌고 있었다. 워크로드를 이중화해도 진입점이 한 노드에 있으면 그 노드가 서비스의 상한이 된다.
죽은 노드의 파드가 더 건강해 보였다.
전원이 끊긴 kc-lab-2 의 keycloak-0 : Running · READY true · up 0
살아 있는 kc-lab-1 의 keycloak-1 : Running · READY false · up 1
파드 상태는 그 노드의 kubelet 이 보고하는데 노드가 죽으면 보고할 주체가 없어져 마지막 값이 그대로 보인다.
축출까지 걸리는 시간은 설정 두 개의 합이다.
node-monitor-grace-period : 40 초
NoExecute taint 의 tolerationSeconds : 300 초
합 : 5 분 40 초
그리고 축출이 시작돼도 StatefulSet 인 keycloak-0 은 대체 파드를 만들지 않았다. 이름이 같아야 하므로 Terminating 이 풀리기를 기다린다.
## 검증 환경
클러스터 : k3s · 노드 둘
kc-lab-1 : k3s server (컨트롤 플레인) · traefik · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
진입 경로 : 호스트 nginx 가 TLS 를 끝내고 traefik 으로 넘긴다
Keycloak 워크로드 종류 : StatefulSet
노드를 죽인 방법 : virsh destroy · 게스트에 종료 신호를 보내지 않는다
복구 방법 : virsh start
실험대
test-server : Arch Linux, 12GB, WiFi only
측정일 : 증거 원문에 시각만 있고 날짜가 없다
## 재현 조건
1. 노드 둘로 클러스터를 띄우고 한쪽에 데이터베이스를, 다른 쪽에 진입 경로를 둔다.
어느 노드에 무엇이 있는지를 먼저 적어 두지 않으면 두 결과를 가를 수 없다.
2. 워커 노드의 전원을 끊는다.
virsh destroy 로 끊는다. 게스트에 종료 신호가 가지 않아야 갑작스러운 상실이 된다.
3. 끊은 시각을 적고 15 초 간격으로 세 가지를 함께 기록한다.
노드 상태 : kubectl get nodes
파드 상태와 노드 이름 : kubectl get pods -o wide
외부 응답 코드 : 진입점 주소로 요청
4. 같은 시점에 Prometheus 의 up 지표도 읽는다.
파드 상태와 어긋나는지 보려는 것이므로 따로 읽어야 한다.
5. 파드의 tolerationSeconds 를 조회하고 축출이 실제로 일어나는 시점까지 30 초 간격으로 관찰한다.
kubectl get pod <pod> -o jsonpath='{.spec.tolerations}'
6. 대체 파드가 만들어지는지 워크로드 종류별로 나눠 본다.
StatefulSet 과 Deployment 의 동작이 여기서 갈린다.
7. virsh start 로 노드를 되살리고 외부 응답이 돌아오기까지 걸리는 시간을 잰다.
8. 컨트롤 플레인 노드로 2 번부터 다시 한다.
이때는 kubectl 이 통하지 않으므로 외부 응답과 호스트 쪽 기록만으로 관찰한다.
## 본문
<!-- body:start -->
## 무엇이 어느 노드에 있었나
노드는 둘이다. kc-lab-1 이 k3s server 이자 컨트롤 플레인이고 traefik 과 `keycloak-1` 이 여기 있다. kc-lab-2 는 agent 이고 `keycloak-0` 과 PostgreSQL, Redis 가 함께 있다. 호스트의 nginx 가 TLS(Transport Layer Security) 를 끝내고 kc-lab-1 의 traefik 으로 넘긴다.
![호스트 nginx 가 kc-lab-1 의 traefik 으로 프록시하고, 그 아래 두 VM 에 Keycloak 과 데이터 저장소가 나뉘어 있는 구성](../../../final/assets/lab-topology/lab-topology.svg)
Keycloak 은 두 노드에 하나씩 떠 있어 이중화돼 있다. 저장소는 그렇지 않다. PostgreSQL 과 Redis 가 kc-lab-2 에만 있으므로 그 노드를 잃으면 세션을 읽고 쓸 곳이 함께 사라진다. 이 배치가 아래 두 결과를 갈랐다.
## 워커를 끊었더니 살아남은 Keycloak 이 쓸모가 없었다
`virsh destroy` 로 kc-lab-2 의 전원을 끊었다. 이 명령은 게스트에 ACPI(Advanced Configuration and Power Interface, 전원 관리 규격) 신호를 보내지 않고 가상머신 프로세스를 끊으므로, 게스트 쪽에서는 예고가 없다. `virsh shutdown` 은 쓰지 않았다. 그쪽은 종료 신호를 보내서 kubelet 이 정상 종료하고 파드를 정리하므로, 쿠버네티스가 정상적인 노드 이탈로 처리해 이 실험의 발견 두 개가 통째로 안 나온다.
```text label="워커 노드를 끊은 직후"
=== 워커 노드(kc-lab-2) 전원 차단 — virsh destroy 는 종료 신호가 없다 ===
차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed
+45초 node=NotReady | keycloak-0=Running | 외부 HTTP 503
```
기록을 처음부터 따라가면 응답 코드가 두 번 바뀐다. `+15초` 와 `+30초` 에는 노드가 아직 `Ready` 였고 외부 응답이 `000` 이었다. `+45초` 부터 노드가 `NotReady` 로 바뀌면서 응답이 `503` 이 됐고, 관찰을 멈춘 `+180초` 까지 `503` 이 이어졌다. 처음 40 초 동안 연결 자체가 안 된 이유를 증거 원문은 진입점 nginx 의 upstream 으로 적어 두었는데, 그 제목 아래에 출력이 없어서 여기서는 그 40 초의 원인까지는 말하지 못한다.
살아남은 쪽은 kc-lab-1 의 `keycloak-1` 이다. 프로세스는 돌고 있었지만 세션을 읽을 PostgreSQL 이 같은 순간에 사라졌으므로 로그인도 토큰 갱신도 처리할 수 없었다. 이 구성에서 워커 상실은 Keycloak 을 한 대 잃는 사건이 아니라 데이터베이스를 잃는 사건이다.
## 컨트롤 플레인을 끊었더니 돌고 있는데 닿을 수 없었다
kc-lab-1 을 같은 방법으로 끊으면 외부 응답이 `503` 도 아니고 `000` 이 된다. TLS 를 끝낸 nginx 가 넘길 traefik 이 없어서 연결이 맺어지지 않는다. 같은 노드에 있던 k3s API 서버도 사라지므로 `kubectl` 이 통하지 않고, 그래서 클러스터 안을 들여다보며 진단할 방법도 함께 없어진다.
이때 kc-lab-2 의 `keycloak-0` 은 계속 돌고 있었다. 같은 노드에 PostgreSQL 도 있으니 세션을 읽을 수도 있었다. 그런데 밖에서 들어갈 길이 없어서 장애였다.
![kc-lab-2 를 잃으면 데이터베이스가 함께 사라지고, kc-lab-1 을 잃으면 진입 경로가 사라지는 구성](../../../final/assets/a4-two-node-losses/a4-two-node-losses.svg)
| 무엇을 잃었나 | 밖과 안에서 무엇이 보였나 |
|---|---|
| 워커 kc-lab-2 | 외부 `503` · `kubectl` 정상 · 데이터베이스가 같이 죽었다 |
| 컨트롤 플레인 kc-lab-1 | 외부 `000` · `kubectl` 불통 · 들어갈 길이 없다 |
복구는 양쪽 다 `virsh start` 이후 60 초로 같았다. 복구 시간이 같아도 대비하는 방법은 갈린다. 워커 쪽은 저장소를 두 노드에 나누거나 밖으로 빼는 문제이고, 컨트롤 플레인 쪽은 진입점을 이중화하는 문제다.
## 죽은 파드가 더 건강해 보였다
여기까지가 노드를 잃는 두 경우를 가르려고 잰 것이고, 재는 동안 예상하지 못한 것이 셋 더 나왔다. 파드 상태가 뒤집혀 보이는 것과, 축출까지 5 분 40 초가 걸리는 것과, StatefulSet 이 대체 파드를 만들지 않는 것이다. 이 절과 다음 절이 그 셋을 적는다.
워커를 끊은 동안 `kubectl get pods -o wide` 와 Prometheus 를 같은 순간에 읽으면 두 값이 어긋난다.
| 어느 노드의 파드인가 | kubectl 과 up 이 말한 것 |
|---|---|
| 전원이 끊긴 kc-lab-2 의 `keycloak-0` | Running · READY `true` · up `0` |
| 살아 있는 kc-lab-1 의 `keycloak-1` | Running · READY `false` · up `1` |
파드 상태는 그 파드가 있는 노드의 kubelet 이 API 서버에 보고한다. 노드가 죽으면 보고하는 주체가 함께 사라지므로 아무도 그 상태를 갱신하지 못하고, 마지막으로 보고된 `Running` 과 `READY true` 가 화면에 계속 떠 있게 된다. 반대쪽 `keycloak-1` 은 살아 있으니 kubelet 이 계속 보고하는데, 데이터베이스를 잃어 readiness 프로브가 실패하므로 `READY false` 로 정직하게 내려간다.
Prometheus 는 kc-lab-1 에 있어서 살아남았고 `keycloak-0` 을 긁지 못해 `up` 을 `0` 으로 적었다. 두 화면 가운데 `kubectl` 쪽이 뒤집혀 있었다. 이 실험에서 `kubectl get pods` 의 STATUS 를 그대로 믿었다면 살아 있는 노드를 장애로, 죽은 노드를 정상으로 판단했을 뻔했다.
## 6 분 가까이 아무 일도 일어나지 않는다
노드가 죽었다고 쿠버네티스가 곧바로 파드를 옮기지는 않는다. 두 단계를 거친다. 컨트롤러가 노드를 `NotReady` 로 판정하기까지 `node-monitor-grace-period` 40 초가 걸리고, 그렇게 붙은 `NoExecute` taint 를 파드가 견디는 시간이 `tolerationSeconds` 300 초다. 이 실험대에서 조회한 파드의 `tolerationSeconds` 는 `not-ready` 와 `unreachable` 양쪽 다 300 이었다. 40 초 쪽은 조회하지 않았다. 쿠버네티스 기본값을 그대로 적었고, 이 실험대가 직접 읽어 확인한 것은 `tolerationSeconds` 쪽이었다. 설정값을 더하면 5 분 40 초다.
관찰 기록에서는 축출이 이렇게 나타난다.
```text label="축출 관찰 — +270초 뒤로는 같은 줄이 반복돼 줄였다"
+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
+420초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
```
`+240초` 까지 네 파드가 전부 `Running` 이었고 `+270초` 에 세 파드가 한꺼번에 `Terminating` 으로 바뀌었다. 다만 이 관찰 기록의 `+` 가 앞의 응답 코드 기록과 같은 시각에서 출발했는지는 원문이 말해 주지 않는다. 응답 코드를 적은 증거 파일은 머리말에 `차단 시각: 12:07:43` 을 적어 두었는데 축출을 적은 파일에는 그 줄이 없다. 그래서 관측된 `+270초` 와 설정값의 합 5 분 40 초를 빼서 쓰지는 않는다. 설정으로 정해진 값과 눈으로 본 전환이 각각 이 크기라는 데까지가 이 측정이 대는 것이다.
바뀐 뒤로도 화면은 움직이지 않았다. `keycloak-0` 은 `+420초` 까지 `Terminating` 이었고 대신 들어올 파드가 나타나지 않았다. StatefulSet 은 파드 이름이 안정적이어야 하므로 같은 이름을 두 개 띄울 수 없고, `Terminating` 인 파드가 완전히 지워지기 전에는 대체를 만들지 않는다. 노드가 죽어 지워지지 못하면 계속 기다린다. Deployment 인 PostgreSQL 쪽은 이름 제약이 없어 새 파드가 곧바로 만들어졌는데, 그 파드는 `+420초` 까지 `Pending` 에서 움직이지 않았다.
장애 시간을 나눠 보면 복구 절차가 차지하는 몫이 작다. 노드를 되살리고 나서 서비스가 돌아오기까지는 60 초이고, 그 앞에는 클러스터가 노드의 죽음을 인정하고 축출을 시작하기까지 기다리는 구간이 있다. 이 실험이 대는 것은 그 구간이 복구 구간보다 길었다는 방향까지이고, 그 길이가 5 분 40 초였다는 것까지는 아니다.
## 분단은 이 두 가지에 들어가지 않는다
노드 사이를 갈라 보는 실험(A-5)은 같은 종류의 전면 장애를 만들지 못했다. 한 방향만 막으면 열린 방향으로 다시 연결되므로 클러스터가 갈라지지 않고, 양방향을 다 막으면 갈라지기는 하는데 한쪽만 `DOWN` 이 되어 코디네이터 쪽이 살아남는다. 분단된 쪽은 스스로 로드밸런서에서 빠지므로 서비스는 이어진다.
그래서 이 실험대에서 전면 장애로 가는 길은 둘뿐이다. 저장소가 같이 죽거나, 들어갈 길이 사라지거나.
## 이번에 재지 않은 것
저장소를 두 노드에 나눠 배치한 구성에서는 재지 않았다. 워커 상실이 데이터베이스 상실과 같은 사건이 된 것은 PostgreSQL 과 Redis 가 kc-lab-2 한 곳에 있었기 때문이고, 그 결과는 이 배치에 걸려 있다. 저장소를 복제하거나 클러스터 밖에 두면 워커 상실의 결과가 달라진다.
컨트롤 플레인을 잃은 동안 `keycloak-0` 이 어디까지 처리할 수 있었는지도 재지 않았다. 밖에서 들어갈 길이 없어 요청을 보낼 방법이 없었고, 노드 안에서 직접 요청을 넣어 확인하지는 않았다.
워커 상실의 처음 40 초가 `000` 이었던 이유는 증거 원문에 제목만 있고 출력이 없다.
<!-- body:end -->
@@ -0,0 +1,114 @@
---
kind: CONCEPT
slug: readiness-hides-the-broken-node
title: readiness 가 깨진 노드를 시야에서 먼저 치운다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
basisVersion: k3s · Kubernetes readiness probe · Keycloak 26.7.0 /health
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#검토한-선택지와-막힌-지점-관측을-어디에
assets:
- key: observation-points
file: ../../../final/assets/observation-points/observation-points.svg
evidence:
- ../../../final/evidence/raw/a1-jgroups-transport-block__11-service-impact.txt
- ../../../final/evidence/raw/a2-database-loss__05-recovery.txt
- ../../../final/evidence/raw/a4-node-loss__03-state-during-loss.txt
- ../../../final/evidence/raw/a6-latency-injection__04-pool-under-load.txt
- ../../../final/evidence/raw/a6-latency-injection__05-recovery.txt
---
# readiness 가 깨진 노드를 시야에서 먼저 치운다
readiness 프로브가 실패한 파드는 Service 엔드포인트에서 빠져, 밖에서 재면 장애가 200 으로 보인다. A-1 에서 실제로 그랬다. A-4 는 방향이 반대로, kubelet 이 멈춰 죽은 파드가 READY 로 남았다.
## 관계
- **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것**
거기서 죽은 노드의 파드가 READY 로 남고 살아 있는 노드의 파드가 READY 에서 빠졌다. 그 역전은 kubelet 이 상태를 더 보고하지 못한 데서 나왔다.
- **200 밀리초를 넣었더니 응답이 22.2 초가 됐다**
그 실험에서도 readiness 프로브가 타임아웃으로 실패했는데, 엔드포인트 목록까지는 읽지 않았다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
readiness 와 `up` 은 같은 파드를 보면서 서로 다른 조건으로 1 과 0 을 낸다.
## 본문
<!-- body:start -->
## 프로브 결과가 엔드포인트 목록까지 가는 경로
파드 하나가 트래픽을 받을지는 네 곳을 거쳐 정해진다. kubelet 이 그 노드에 있는 파드의 readiness 프로브를 주기적으로 호출하고, 결과를 파드 상태의 READY(ready, 트래픽을 받을 준비가 됐는지) 값으로 API 서버에 보고한다. 엔드포인트 컨트롤러가 그 값을 읽어 Service 의 주소 목록을 ready 와 notReady 로 나누게 되고, 프록시는 ready 쪽 주소로만 요청을 넘긴다. 이 실험대에서는 그 앞에 traefik 과 호스트 nginx 가 더 있어서, 외부에서 들어온 로그인 요청은 ready 로 남은 파드에만 도착한다.
Keycloak 26.7.0 은 관리 포트 9000 에 헬스와 메트릭을 연다. 프로브가 부르는 주소는 파드 이벤트에 그대로 찍히는데, A-4 의 이벤트 목록에는 `http://10.42.1.67:9000/health/ready` 로 남아 있다. A-1 에서 NetworkPolicy 로 JGroups 전송 포트 7800 을 막을 때 8080 과 9000 을 허용 목록에 남긴 이유가 이 경로에 있다. 9000 을 빠뜨리면 프로브가 응답을 못 받아 kubelet 이 파드를 죽이므로, 분단을 재려던 실험이 죽은 Keycloak 을 재는 실험으로 바뀐다.
## A-1 에서 그 경로가 한 번에 보인다
A-1 은 NetworkPolicy 의 허용 목록에서 7800 을 빼서 두 노드를 갈라놓은 실험이다. NetworkPolicy 는 허용목록이라 「deny 7800」 같은 규칙을 쓸 수 없어서, 8080 과 9000 만 열고 7800 을 목록에서 빼는 방식으로 막았다. 갈라진 뒤 파드 상태와 Service 엔드포인트와 외부 로그인을 같은 시점에 읽었다.
| 어디를 읽었나 | 무엇이 찍혀 있었나 |
|---|---|
| 파드 READY | keycloak-0 false · keycloak-1 true |
| Service 엔드포인트 | ready 주소 10.42.0.35 · notReady 주소 10.42.1.67 |
| 외부 진입점 | `/realms/master` 200 · 토큰 발급 200 |
분단된 keycloak-0 이 프로브에 실패해 READY 에서 빠졌고, 엔드포인트 목록에서도 notReady 쪽으로 옮겨졌다. 남은 ready 주소가 하나뿐이어도 Service 는 그쪽으로 요청을 넘기므로 외부 로그인은 200 이 된다. 밖에서만 보면 이 실험은 「아무 일도 없음」으로 끝난다.
Keycloak 은 클러스터 분단을 readiness 로 신고한다. 그때 `/health/ready` 가 돌려준 JSON 은 데이터베이스 연결 검사를 `UP` 으로 두고 클러스터 검사만 `DOWN` 으로 적었다. liveness 로 신고했다면 kubelet 이 파드를 재시작했을 텐데 분단은 재시작해도 안 나아지므로, 격리 쪽인 readiness 가 맞는 신호다.
이 결과는 노드 둘 가운데 하나만 분단된 조건에서 나왔다. 양쪽이 함께 프로브에 실패하면 어떻게 되는지는 A-1 이 남기지 못했는데, 그 값을 찍으려던 증거 파일의 명령이 JSON 파싱 오류로 끝나 출력 자리가 비어 있다.
## 관측 지점을 셋으로 늘린 이유
처음에는 밖에서만 쟀다. `curl` 로 외부 진입점을 찍고 상태 코드를 세는 방식이었는데, A-1 에서 그 방식이 무너졌다. 그래서 관측 지점을 셋으로 늘렸다.
| 어디서 재나 | 무엇을 보는가 |
|---|---|
| 외부 `curl` | 사용자가 겪는 것 |
| Prometheus 지표 | `vendor_cluster_size` · `vendor_jgroups_*` · `agroal_*` |
| PostgreSQL 직접 조회 | 실제로 무엇이 저장됐는가 |
![외부 curl 과 Prometheus 지표와 PostgreSQL 직접 조회 세 지점이 같은 Keycloak 클러스터의 서로 다른 층을 보는 구성](../../../final/assets/observation-points/observation-points.svg)
세 지점이 서로 다른 층을 보므로, 하나만 두면 그 지점이 보지 못하는 상태를 아무도 읽지 못한다.
Prometheus 의 `up` 도 readiness 와 다른 조건으로 값을 낸다. A-2 에서 데이터베이스를 멈춰 서비스가 503 을 내는 동안 `up{pod=keycloak-0}``up{pod=keycloak-1}` 은 둘 다 1 이었다. 프로세스가 살아 있고 `/metrics` 가 응답하기만 하면 1 이 되므로, 「살아 있지만 쓸모없는」 상태는 이 지표에 나타나지 않는다.
## 갱신이 멈추면 같은 값이 반대로 읽힌다
A-4 는 워커 노드 kc-lab-2 의 전원을 끊은 실험이고, 여기서는 READY 값이 A-1 과 반대 방향으로 어긋났다.
| 어느 파드를 읽었나 | 그때 무엇이 찍혀 있었나 |
|---|---|
| 전원이 끊긴 kc-lab-2 의 keycloak-0 | Running · READY true · `up` 0 |
| 살아 있는 kc-lab-1 의 keycloak-1 | Running · READY false · `up` 1 |
파드의 READY 값은 그 노드의 kubelet 이 보고하는데, 노드가 사라지면 보고할 주체가 없어져 API 서버가 마지막으로 받은 값을 계속 돌려준다. 그래서 죽은 파드가 산 파드보다 건강해 보인다. Prometheus 는 그 파드를 직접 스크레이프하므로 같은 시점에 `up` 을 0 으로 적었고, 두 신호가 서로 반대를 가리켰다.
살아 있는 쪽의 keycloak-1 이 READY 에서 빠진 이유는 이벤트에 남아 있다. `/health/ready` 가 503 을 돌려줬고, kc-lab-2 에 함께 있던 PostgreSQL 이 노드와 같이 사라져 세션을 읽을 곳이 없었기 때문이다.
A-1 과 A-4 를 같은 문장으로 묶지 않는다. A-1 에서는 프로브가 제때 실패해서 그 파드가 엔드포인트 목록에서 빠졌고, 그 목록을 직접 읽어 확인했다. A-4 에서는 프로브 결과가 더 갱신되지 않았고, **그때 엔드포인트가 어떻게 됐는지는 읽지 않았다** — A-4 의 관찰은 파드 상태와 이벤트와 up 셋뿐이다. 앞쪽은 readiness 가 동작해서 생긴 착시이고, 뒤쪽은 readiness 가 멈춰서 생긴 착시인데, 뒤쪽에서 엔드포인트까지 그대로였는지는 이 실험이 답하지 않는다.
## A-6 은 프로브 실패까지만 남겼다
A-6 은 200 밀리초 네트워크 지연을 주입한 실험이다. 동시 부하 20 건을 keycloak-1 에 넣은 직후 이벤트 목록에 readiness 프로브가 `context deadline exceeded` 로 실패한 줄이 있고, 프로브 주소는 `http://10.42.0.42:9000/health/ready` 다.
거기서 멈춘다. 그 시점에 Service 엔드포인트를 다시 읽어 keycloak-1 이 notReady 로 옮겨졌는지 확인한 값이 없고, 재시작 횟수는 지연 구간 앞뒤로 같다. 부하 직후 파일과 지연 해제 뒤 파일 모두 두 파드를 `1/1 Running` 으로 적고 keycloak-1 의 RESTARTS 를 1 로 적는데, 괄호 안의 경과 시간만 51 분 전에서 52 분 전으로 넘어간다.
그래서 A-6 에서 읽은 것은 프로브가 타임아웃으로 실패했다는 사실 하나다. 느린 노드가 실제로 엔드포인트에서 빠졌는지, 그다음에 파드가 새로 죽었는지는 아직 읽지 않은 값이다. 확인하려면 지연을 주입하고 있는 동안 Service 엔드포인트 목록과 파드 재시작 횟수를 같은 시점에 읽어야 한다.
## 이 동작이 막지 않는 것
readiness 는 트래픽을 받을 파드를 고르는 장치이고, 장애를 알리는 장치가 아니다. 프로브가 정확히 실패할수록 외부 상태 코드는 깨끗해지므로, 밖에서 코드만 세는 관측은 정상과 「한쪽이 빠진 채로 버티는 중」을 구분하지 못한다.
엔드포인트를 ready 와 notReady 로 나누는 것은 쿠버네티스의 동작이고, 이 실험대가 읽은 것은 그 동작이 실험마다 어디까지 진행됐는지다. 그 진행이 세 실험에서 서로 달랐다.
| 어느 실험인가 | 어디까지 읽었나 |
|---|---|
| A-1 · JGroups 전송 차단 | 파드 READY · 엔드포인트 목록 · 외부 응답 |
| A-4 · 워커 노드 상실 | 파드 READY · `up` · 프로브 실패 이벤트 |
| A-6 · 지연 주입 | 프로브 실패 이벤트 |
<!-- body:end -->
@@ -0,0 +1,83 @@
---
kind: REFERENCE
slug: most-of-an-outage-is-noticing
title: 장애 시간의 대부분은 알아채는 데 걸린다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
source:
- final/document.md#선택의-이유와-지킨-경계-a4
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
evidence:
- ../../../final/evidence/raw/a4-node-loss__02-worker-node-killed.txt
- ../../../final/evidence/raw/a4-node-loss__03-state-during-loss.txt
- ../../../final/evidence/raw/a4-node-loss__04-eviction-timing.txt
- ../../../final/evidence/raw/a4-node-loss__05-recovery.txt
- ../../../final/evidence/raw/a4-node-loss__08-control-plane-recovery.txt
---
# 장애 시간의 대부분은 알아채는 데 걸린다
노드를 잃으면 복구보다 알아채는 데 시간이 더 걸린다. 그래서 복구 절차보다 감지 쪽을 먼저 손본다. A-4 에서 재기동 뒤 서비스 복귀는 60초였고, 폴링이 축출을 본 것은 +240초와 +270초 사이다.
## 관계
- **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것**
이 기준이 나온 실험이다. 4a 와 4b 의 관측이 거기 있다.
- **200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다**
감지와 복구를 다 통과해도 돌아오지 않는 것이 있다. 그 손실을 센 기록이다.
## 목적
복구 절차만 다듬어 두고 그 절차가 시작되기까지 걸린 시간은 세지 않는 것을 막는다.
A-4 에서 파드 목록은 +240초까지 Running 그대로였는데, 밖에서는 이미 갈렸다 — +30초에 000, +45초에 503 이다. 재기동한 뒤 외부 응답이 200 으로 돌아오는 데는 60초가 걸렸다. 복구 쪽만 재면 이 장애는 60초짜리로 기록된다.
## 규칙
### 1. 감지 구간과 복구 구간을 따로 센다
두 구간은 시작점이 다르다. +240초와 +270초는 축출을 지켜본 폴링이 적은 시각이고, 그 폴링이 무엇을 +0 으로 잡았는지는 이 실험이 적어 두지 않았다. 차단 시각을 머리말에 적은 증거 파일은 `02-worker-node-killed` 하나이고, 축출을 지켜본 `04-eviction-timing` 에는 그 줄이 없다. 60초는 사람이 virsh start 를 친 뒤부터 외부 응답이 200 으로 돌아오기까지다.
A-4 는 그 구간도 쟀다. 차단이 12시 07분 43초, 서비스 복귀가 12시 17분 31초로 약 10분이고, 가이드는 그 대부분이 사람이 관찰하고 결정하는 데 쓴 시간이라고 적는다. 복구 60초는 virsh start 를 친 뒤의 시간이라 MTTR 이 아니다. 두 수를 더해 장애 시간이라고 부르면 그 사람 구간이 장부에서 사라진다.
### 2. 타이머 설정값을 더해 감지 시간을 예측하지 않는다
A-4 에서 node-monitor-grace-period 40초와 tolerationSeconds 300초를 더하면 340초인데, 폴링이 축출을 본 것은 +240초와 +270초 사이다. 두 수가 같은 기준점에서 온 것이라면 70초 이상 어긋나는 것이고, 그렇지 않다면 견줄 수 없는 두 값이다. 두 폴링이 같은 +0 을 쓰는지 이 실험이 적어 두지 않아서, 둘 중 어느 쪽인지 고를 근거가 없다.
기다릴 시간을 잡는 데는 340초라는 계산으로 충분하지만, 결과로 적을 때는 잰 쪽을 적는다.
### 3. 조회한 값과 기본값을 같은 신뢰도로 적지 않는다
A-4 의 tolerationSeconds 300 은 kubectl 로 직접 읽었고 not-ready 와 unreachable 둘 다 같은 값이었다. node-monitor-grace-period 40초는 조회하지 않고 쿠버네티스 기본값에서 옮겨 적었다. 표에 나란히 놓으면 둘 다 잰 값으로 읽힌다. 어디서 왔는지를 값 옆에 적는다.
### 4. 파드 상태를 노드 생존의 근거로 쓰지 않는다
kubelet 이 사라지면 그 노드의 파드 상태가 갱신되지 않아 Running 으로 남는다. 축출 타이머가 다 돌기 전까지는 죽은 파드가 산 파드보다 건강해 보인다.
### 5. 대체 파드가 만들어지는 조건을 워크로드 종류마다 확인한다
StatefulSet 은 Terminating 파드의 대체를 만들지 않는다. 이름이 같아야 하므로 앞의 파드가 지워지기를 기다린다. A-4 4a 에서 본 것이고 다른 워크로드 종류로는 돌려 보지 않았다.
## 적용 조건
- 노드 상실을 오케스트레이터가 감지해 대체를 만드는 구성. 이 프로젝트가 본 것은 k3s 한 벌이다
- 장애 시간을 숫자로 보고할 때. 그 숫자가 어느 구간을 센 것인지 밝혀야 한다
- 감지와 축출 타이머 값을 문서에 옮겨 적을 때
- StatefulSet 처럼 파드 이름이 고정된 워크로드를 노드 상실에 대비시킬 때
## 예외
- 진입 경로 자체를 잃은 장애는 감지가 빨라도 복구되지 않는다. 4b 에서 keycloak-0 은 계속 돌고 있었는데도 외부 응답이 000 이었고 kubectl 도 불통이었다. 그때는 이중화 지점이 문제다
- 진입점이 단일 노드에 있으면 워크로드를 이중화해도 소용이 없다. 다만 진입점을 이중화한 구성은 이 실험대가 돌려 보지 않았다
- 다른 오케스트레이터는 이 프로젝트가 보지 않았다. 여기 적힌 타이머 이름과 값은 k3s 밖에서 성립하지 않는다
## 예시
- 노드를 끄고 +30초에는 Ready, +45초에는 NotReady 였다. 그 사이 15초보다 좁게는 재지 않았다
- +240초까지 파드는 Running 이었고 +270초에 Terminating 으로 바뀌었다
- tolerationSeconds 는 not-ready 와 unreachable 둘 다 300 이었다. kubectl 로 읽은 값이다
- node-monitor-grace-period 40초는 조회하지 않았다. 쿠버네티스 기본값을 옮겨 적은 것이다
- 4a 와 4b 모두 virsh start 이후 60초에 서비스가 돌아왔다
- 4a 의 외부 응답은 503 이었고 kubectl 은 정상이었다. 4b 는 외부가 000 이고 kubectl 이 불통이었다
@@ -0,0 +1,712 @@
---
id: bf169fef-f900-4247-88f8-427742ae3fe9
kind: SETUP
slug: reproduce-a2-database-loss
title: PostgreSQL 을 정상 종료시키고 네 경로를 잰다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/bf169fef-f900-4247-88f8-427742ae3fe9/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-2
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# PostgreSQL 을 정상 종료시키고 네 경로를 잰다
PostgreSQL 을 정상 종료시키고 refresh 와 새 로그인과 관리 API 조회가 각각 어떻게 되는지 주입 전후에 같은 명령으로 재는 절차다. 정문이 실제로 `503` 이 되므로 정지 구간을 1분 남짓으로 짧게 잡고, 복구는 데이터베이스를 다시 켜는 것 하나다.
## 관계
- **200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다**
정상 종료와 강제 종료의 차이를 건수로 보여 주는 기록이다. 이 절차는 그 대조군인 정상 종료 쪽을 만든다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
정문이 `503` 인 동안 `up` 이 양쪽 다 1 이었던 것을 다룬다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
Ready 파드가 0개가 되고 `ready` 주소가 빈 목록이 되는 경로를 다룬다.
- **세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다**
먼저 해 둬야 하는 편이다. 세션을 데이터베이스가 공유한다는 것을 손으로 확인해 두지 않으면 여기서 나오는 `500` 을 해석할 수 없다.
- **PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다**
같은 데이터베이스를 SIGKILL 로 죽이는 편이다. 정상 종료가 아무것도 잃지 않는다는 이 편의 결과가 그쪽의 대조군이 된다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 `kubectl``psql` 로 친다. 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl``sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
터미널은 둘을 연다. 하나는 탐침 파드 셸용이라 붙잡혀 있고, 하나는 관찰용이다. 그래서 `[kc-lab-1]` 라벨이 붙은 블록이 `[탐침 파드]` 블록 사이에 끼어 있으면 **관찰용 터미널에서 친다** — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 `exit` 를 블록으로 따로 적는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 주입 수단 | `scale deployment/postgres --replicas=0` — 정상 종료다 |
| 탐침 파드 | `a2-probe``curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 재는 경로 | 넷 — 캐시 있는 노드 refresh · 없는 노드 refresh · 새 로그인 · 관리 API |
| 시간 제약 | access token 수명 60초. 토큰 발급 · 정지 · 시험을 그 안에 끝낸다 |
| 정지 구간 | 1분 남짓. 그동안 정문이 실제로 `503` 이 된다 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
## 이 실험이 가르는 것
A-1 에서 룩어사이드 캐시는 읽을 때 데이터베이스와 대조하지 않는다는 것을 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 `200` 을 줬다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 캐시가 DB 를 대신한다면 그 노드는 살아남아 부분 장애가 되고, 대신하지 못한다면 전면 장애가 된다.
A-1 과의 대비가 이 실험의 값이다.
```text
A-1 7800 차단 → 한쪽만 빠지고 서비스는 계속됐다 (용량 저하)
A-2 DB 정지 → ? (여기서 판정)
```
네 경로를 구분해서 본다. 하나만 재면 무엇 때문에 죽었는지 모른다.
| # | 경로 | 무엇을 보는가 |
|---|---|---|
| ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 |
| ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 |
| ③ | 새 로그인 | 쓰기 경로 |
| ④ | 이미 발급된 토큰으로 관리 API 조회 | 서명만으로 되는 경로가 있는가 |
절차를 끝까지 밟으면 캐시에 세션을 가진 노드도 refresh 가 `500` 인 것, JWKS 와 `.well-known``200` 으로 살아 있는 것, Ready 파드가 0개이고 `ready` 주소가 빈 목록인 것, 정문이 `503` 을 주는 것, 헬스 네 항목 중 `database connections` 만 DOWN 인 본문, `up = 1` 인 채로 전면 장애가 나 있는 것, 15초 만에 재시작 0회로 스스로 돌아오는 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- **A-0 을 먼저 한다.** 세션은 DB 가 공유한다는 것을 손으로 확인해 두지 않으면 이 실험의 `500` 을 해석할 수 없다.
- A-1 의 분단이 풀려 있어야 한다. `vendor_cluster_size` 가 양쪽 `2` 가 아니면 두 실험이 섞인다.
**이건 전면 장애를 만드는 실험이다.** 정문(`https://auth.hyeonworks.com`)이 실제로 `503` 이 된다. 이 실험대를 쓰는 다른 작업이 있으면 멈춘다. 정지 구간은 **1분 남짓**으로 짧게 잡는다. 되돌리는 명령은 아래 한 줄이다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
```
## 주입 전에 같은 명령으로 먼저 본다
```text
파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험
```
### 1. postgres 가 어느 노드에 있는가
**무엇을 보는가** — 세 파드의 상태와 배치.
```bash label="[kc-lab-1] 파드 배치를 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — 실측은 이렇다(observed, `01-baseline.txt`).
```text
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
```
**이 값이 뜻하는 것** — 원래 실행에서 `postgres` 는 `kc-lab-2`, 즉 `keycloak-0` 과 같은 노드에 있었다. 이 실험에서는 상관없지만 A-4(노드 상실)에서는 결정적이다 — 그 노드를 죽이면 A-2 가 함께 일어난다.
### 2. 파드 주소 두 개를 변수에 담는다
**무엇을 보는가** — 뒤의 모든 요청이 향할 주소.
```bash label="[kc-lab-1] 파드 주소를 변수에 담는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**어디를 보나** — 실측은 `keycloak-0=10.42.1.67 keycloak-1=10.42.0.35` 다(observed).
### 3. 클러스터가 정상인지 먼저 확인한다
**무엇을 보는가** — 두 노드가 아는 멤버 수.
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
라벨을 보고 나서 자른다. 아래 줄은 가이드가 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ② 필요한 줄만 자른다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**어디를 보나** — 실측은 `cluster_size keycloak-1 = 2`, `cluster_size keycloak-0 = 2` 다(observed).
**이 값이 뜻하는 것** — 여기가 `1` 이면 A-1 의 분단이 안 풀린 것이고, 그 위에서 재면 두 실험이 섞인다.
### 4. 상주 탐침 파드를 띄운다
**무엇을 보는가** — 계측 도구를 A-1 에서 바꾸는 까닭.
`--rm` 임시 파드는 매번 만들고 지우므로 느리고 경합이 있고, 토큰을 단계 사이로 넘길 수 없다. 이 실험은 DB 정지 전에 발급한 토큰을 정지 후에 써야 하므로 파드를 하나 띄워 두고 `exec` 로 단계를 이어간다.
```bash label="[kc-lab-1] ① 상주 탐침 파드를 띄우고 Ready 까지 기다린다"
kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
```
`--rm` 이 없으므로 끝나면 직접 지운다. 지우는 명령은 복구 절에 있다.
비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 확인한다.
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
실측은 `19` 다(observed).
```bash label="[kc-lab-1] ③ 파드 안에도 값이 들어왔는지 길이로 본다"
kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이므로 파드를 지우고 다시 띄운다. 이제부터는 파드 셸에 들어가 친다. 나올 때는 `exit` 이고 파드는 안 지워진다.
```bash label="[kc-lab-1] ④ 파드 셸로 들어간다"
kubectl -n keycloak-lab exec -it a2-probe -- sh
```
### 5. 양쪽 노드에 세션을 하나씩 만든다
**무엇을 보는가** — ① 과 ② 를 구분하려면 캐시를 가진 노드와 없는 노드가 있어야 한다. A-0 에서 확인한 성질을 그대로 쓴다 — 각 노드는 자기가 로그인시킨 세션만 캐시한다.
```sh label="[탐침 파드] 두 노드에 각각 로그인하고 sid 를 뽑는다"
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
```
**어디를 보나** — 실측은 이렇다(observed, `02-setup-sessions.txt`).
```text
=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
```
**이 값이 뜻하는 것** — 빈 줄이 나오면 로그인이 실패했거나 base64 패딩 때문에 sid 를 못 뽑은 것이므로 응답 전체를 한 번 그대로 본다.
### 6. 세션이 각자 노드에만 캐시됐는가
```bash label="[kc-lab-1] 노드별 캐시 엔트리"
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]'
```
실측은 이렇다(observed, `02-setup-sessions.txt`).
```text
=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
keycloak-1 = 0 건
keycloak-0 = 1 건
```
`keycloak-1` 이 `0` 인 것은 스크레이프 지연 때문이다. 방금 로그인했으므로 다음 15초 스크레이프에서 `1` 이 될 수 있고, 원래 실행 기록에도 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」으로 적혀 있다. 판정에 쓰는 값은 양쪽이 다른지가 아니라 `keycloak-0` 이 확실히 가지고 있는지다.
```bash label="[kc-lab-1] DB 세션 수도 적어 둔다"
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"
```
실측은 온라인 세션 `2` 다(observed). 복구 후에 세션이 살아남았는지 볼 대조군이므로 적어 둔다.
### 7. ④ 에 쓸 클라이언트 id 를 미리 뽑는다
**무엇을 보는가** — DB 가 죽은 뒤에는 이 조회 자체가 실패하므로 지금 뽑아 둔다.
```sh label="[탐침 파드] ① 응답을 한 번 그대로 본다"
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"
```
잘라내는 줄은 미검증이다(unknown).
```sh label="[탐침 파드] ② 첫 번째 id 만 잘라낸다"
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"
```
### 8. 네 경로를 정상 상태에서 한 번 돌린다
```sh label="[탐침 파드] 토큰 둘을 받고 네 경로를 차례로 친다"
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` 이다.
**이 값이 뜻하는 것** — `-o /dev/null` 을 빼면 안 된다. 빼면 본문과 상태코드가 한 줄에 섞여 나오고, 원래 실행이 정확히 그것을 당했다.
상태가 필요 없는 경로도 미리 재 둔다.
```sh label="[탐침 파드] 서명 검증만 필요한 두 경로"
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"
```
밖에서 정문도 재 둔다.
```bash label="[kc-lab-1] 밖에서 본 정문"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
## 주입
### 1. 데이터베이스를 정상 종료시킨다
**목적** — PostgreSQL 파드를 없애고 다시 만들어지지 않게 한다.
다른 두 방법으로는 이 실험이 성립하지 않는다.
| 방법 | 무엇이 일어나나 |
|---|---|
| **`scale --replicas=0`** | 파드가 정상 종료되고 **다시 만들어지지 않는다** |
| `delete pod` | Deployment 가 **곧바로 새로 만든다** — 몇 초 만에 돌아온다 |
| 노드 정지 | Keycloak 도 같이 죽는다 — **두 장애가 섞인다** |
```bash label="[kc-lab-1] ① 시각을 남기고 replica 를 0 으로 내린다"
date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
```
```bash label="[kc-lab-1] ② 파드가 사라질 때까지 기다리고 시각을 남긴다"
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
date '+%H:%M:%S 삭제완료'
```
**예상 결과** — 실측은 이렇다(observed, `03-four-paths.txt`).
```text
=== [2] PostgreSQL 정지 ===
정지 시각: 11:56:04
deployment.apps/postgres scaled
pod/postgres-7b474b88c8-sn9ff condition met
삭제 완료: 11:56:04
```
두 시각이 같다. 즉시 사라진다. 두 시각은 반드시 적어 둔다 — 뒤에서 「언제부터 변했나」를 볼 때 이 시각이 없으면 인과를 못 붙인다.
**왜 필요한가** — 이것은 정상 종료다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤 내려가므로 데이터는 하나도 잃지 않는다. 강제로 죽였을 때 무엇을 잃는지는 A-3 이 잰다. 그리고 access token 수명이 60초라 위에서 발급한 `AT` 로 ④ 를 재려면 발급 → 정지 → 시험을 60초 안에 끝내야 한다. 60초를 넘기면 ④ 의 `401` 이 DB 때문인지 토큰 만료인지 구별되지 않는다. 시간이 지났으면 토큰을 다시 받아 두되 그건 DB 가 있어야 되는 일이므로 순서는 토큰 발급 다음이 정지다.
**문제가 생기면** — 몇 초 만에 파드가 돌아왔다면 `delete pod` 를 썼다. `scale --replicas=0` 인지 다시 본다.
## 주입 검증
### 1. postgres 파드가 하나도 없는가
```bash label="[kc-lab-1] 파드와 Deployment 를 함께 본다"
kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab get deploy postgres
```
`postgres` 로 시작하는 줄이 한 개도 없어야 하고 Deployment 는 `0/0` 이어야 한다. `0/1` 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다.
### 2. Keycloak 이 실제로 DB 를 못 잡고 있는가
```bash label="[kc-lab-1] 커넥션 풀 쪽 로그를 본다"
kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection'
```
실측은 이렇다(observed, `04-health-and-service.txt`).
```text
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 가 안 죽었다. `timed out` 이 아니라 `Connection refused` 가 나오는 까닭은 Service 는 있고 뒤에 파드가 없어 연결이 즉시 거부되기 때문이다.
### 3. 파드가 죽지 않고 트래픽에서만 빠졌는가
```bash label="[kc-lab-1] Ready 와 재시작 횟수만 뽑아 본다"
kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
| grep keycloak
```
실측은 `keycloak-0 false 0`, `keycloak-1 false 0` 이다(observed). `READY` 가 `false` 인데 `RESTARTS` 는 여전히 `0` 이다. 파드는 죽지 않았고 트래픽에서 빠졌을 뿐이다. `RESTARTS` 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을 재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재게 된다. 이 `restarts=0` 이 자동 회복이라는 결론을 가능하게 하는 조건이다.
## 관찰
탐침 파드 안에서 주입 전과 똑같은 명령을 다시 친다. 파드 셸에서 나갔다 들어오면 `RT0` `RT1` `AT` `CID` 가 사라지므로 셸을 붙잡고 있는 편이 낫다.
```bash label="[kc-lab-1] 셸을 놓쳤으면 다시 들어간다"
kubectl -n keycloak-lab exec -it a2-probe -- sh
```
**여기서 시계를 본다.** 7번에서 `AT` 를 받은 지 60초가 지났으면 ④ 는 `401` 을 준다. 그 `401` 은 DB 때문이 아니라 토큰이 만료돼서 나온 것이라, 이 실험이 재려는 값이 아니다. 60초를 넘겼으면 ④ 는 이 회차에서 못 잰다 — DB 가 없는 동안에는 새 토큰을 받을 수 없다(③ 이 이미 `500` 이다). 복구 절까지 가서 ④ 만 다시 쳐도 안 된다 — 그때는 DB 가 살아 있어 `200` 이 나오므로 이 표가 묻는 값이 아니다. ④ 가 필요하면 복구한 뒤 토큰을 새로 받고 주입부터 다시 돈다. ①·②·③ 은 `AT` 를 쓰지 않으므로 그대로 쓴다.
주입 전 8번에서 친 것과 글자까지 같은 블록이다.
```sh label="[탐침 파드] 네 경로를 다시 친다"
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"
```
실측은 이렇다(observed, `03-four-paths.txt` 와 `04-health-and-service.txt`).
```text
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
③ 새 로그인 HTTP 500
④ 관리 API (세션 조회 필요) HTTP 500
```
본문도 한 번 그대로 본다.
```sh label="[탐침 파드] 500 의 본문을 본다"
curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
```
실측은 이렇다(observed).
```text
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
```
본문이 아무것도 말해 주지 않는다. 원인은 주입 검증에서 본 서버 로그에만 있다.
④ 의 첫 측정은 오염됐다. 원래 실행의 증거 파일에는 이렇게 남아 있다(observed, `03-four-paths.txt`).
```text
④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
```
세 가지가 한 줄에 뭉쳐 있다.
```text
HTTP 000000{"error":"HTTP 401 Unauthorized"}401
─┬──── ──────────┬─────────────────── ─┬─
│ │ └─ 마지막 시도의 상태코드
│ └─ 응답 본문이 그대로 섞였다
└─ 재시도가 세 번 "000" 을 찍었다 (연결 실패)
```
`curl -w '%{http_code}'` 를 쓰면서 `-o /dev/null` 을 빼면 본문이 표준출력으로 같이 나오고, 거기에 `--retry` 까지 걸려 있어 실패한 시도의 `000` 이 앞에 쌓인다. 위 표의 ④ `500` 은 아래 「서명 검증만 필요한 두 경로와 ④」 블록에서 DB 가 아직 내려가 있는 동안 다시 잰 값이다. 첫 측정은 쓰지 않았다 — 오염된 측정은 버리고 다시 잰다.
① 이 `500` 인 것이 이 실험의 답이다. 캐시에 세션을 들고 있어도 refresh 는 실패한다.
```text
refresh 처리
├── 세션이 존재하는가 → 캐시로 답할 수 있다
└── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다
```
A-0 에서 잡은 SQL 그대로다.
```sql label="A-0 의 문장 로그에서 잡은 갱신 문장"
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
```
캐시는 읽기를 대신할 뿐 쓰기를 대신하지 못한다. refresh 는 이름과 달리 쓰기 연산이다.
상태가 필요 없는 경로는 살아남는다. ④ 도 여기서 한 번 더 친다 — 표에 적은 ④ `500` 이 이 블록이 낸 값이다. 셋을 한 블록에 두면 세 경로의 답이 한 화면에 나란히 나온다.
```sh label="[탐침 파드] 서명 검증만 필요한 두 경로와 ④ 를 다시 친다"
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"
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"
```
실측은 이렇다(observed, `04-health-and-service.txt`).
```text
=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
JWKS 엔드포인트(realm 공개키) HTTP 200
realm 메타데이터(.well-known) HTTP 200
관리 API(세션 조회 필요) HTTP 500
```
같은 파드, 같은 포트인데 경로에 따라 `200` 과 `500` 이 갈린다. realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답하고, 이론적으로는 이미 JWKS 를 캐시한 리소스 서버가 토큰 검증을 계속할 수 있다는 뜻이다. 이 실험대에는 독립 리소스 서버가 아직 없으므로 거기까지가 말할 수 있는 범위다.
```bash label="[kc-lab-1] Service 뒤에 누가 남았는지 본다"
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
```
실측은 이렇다(observed, `04-health-and-service.txt`).
```text
=== Service 엔드포인트 ===
ready : [] ← 비었다
notReady: [10.42.0.35 10.42.1.67]
```
`ready` 가 빈 목록이다. `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨고, 원래 실행 기록에도 그 경고가 두 줄 남아 있다(observed).
```text
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 label="[kc-lab-1] 같은 것을 짧게 보는 형태"
kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
```
밖에서 본다. 한 번 눈으로 볼 때는 헤더까지 본다.
```bash label="[kc-lab-1] 정문을 코드로 한 번, 헤더로 한 번"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -I https://auth.hyeonworks.com/realms/master
```
실측은 `https://auth.hyeonworks.com/realms/master HTTP 503` 이다(observed). 이 `503` 은 Keycloak 이 준 것이 아니다. Ready 인 백엔드가 하나도 없어서 그 앞의 프록시가 준 것이고, `200` 이던 JWKS 도 정문으로는 닿지 않는다.
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|---|---|---|
| Ready 인 파드 | `keycloak-1` **1개 생존** | **0개** |
| Service `ready` | `[10.42.0.35]` | **`[]`** |
| 외부 응답 | **200** | **503** |
| 성격 | 용량 저하 | **전면 장애** |
노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다. Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다.
헬스 본문이 까닭을 말한다. Keycloak 이미지에는 `curl` 이 없으므로 파드 밖에서 묻는다. 묻는 명령은 관찰용 터미널에서 치는데 `"http://$K0:9000/..."` 는 큰따옴표라 파드 안이 아니라 그 터미널의 셸이 편다. 두 값은 2번에서 잡았는데 그 터미널을 지금 탐침 파드 셸이 붙잡고 있으므로, 관찰용 터미널에서 두 줄을 다시 친다.
```bash label="[kc-lab-1] 관찰용 터미널에서도 파드 주소를 잡는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
DB 가 없어도 이 두 줄은 된다. 파드 주소는 쿠버네티스 API 가 답하고, 주입 검증 3번에서 본 대로 Keycloak 파드는 재시작하지 않았으므로(`RESTARTS 0`) 값도 2번과 같다. 이 두 줄을 건너뛰고 다음 블록을 치면 `curl -s "http://:9000/health/ready"` 가 되어 URL 이 거부되고 빈 출력이 나온다. 전면 장애 한복판이라 그 빈 줄을 「헬스 엔드포인트까지 죽었다」로 읽기 쉬운데, 실제로는 헬스가 응답하고 네 항목 중 하나만 DOWN 이다.
```bash label="[kc-lab-1] 탐침 파드를 통해 헬스를 묻는다"
kubectl -n keycloak-lab exec a2-probe -- \
curl -s "http://$K0:9000/health/ready"
```
실측은 이렇다(observed, `04-health-and-service.txt`).
```text
=== 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 인지가 장애를 구별한다.
관측의 함정이 여기 있다.
```bash label="[kc-lab-1] up 지표를 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up' \
| tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]'
```
실측은 이렇다(observed, `05-recovery.txt`).
```text
=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
```
Grafana Explore 에서 `up{job="keycloak"}` 을 그려 보면 전 구간 평평하다. 원래 실행의 그림이 `a2-up-stayed-1-during-outage.png` 이고, `11:44` 의 짧은 골은 A-1 에서 파드를 교체한 자국이다. Prometheus 가 `/metrics` 를 긁는 데 성공하기만 하면 `up` 이 1 이 되므로, 프로세스는 멀쩡히 살아 메트릭을 내놓고 기능은 전멸한 상태에서도 1 이다.
| 지표 | 이 장애에서 |
|---|---|
| `up` | **1 — 아무것도 알려주지 않는다** |
| 파드 `Ready` | **false — 여기서 드러난다** |
| 외부 HTTP 코드 | **503 — 사용자가 겪는 것** |
A-0 은 `up` 을 「가장 중요한 합성 지표」라고 썼는데 절반만 맞다. 대상이 사라진 것은 잡지만 대상이 살아서 못 쓰는 것은 못 잡고, 후자가 운영에서 훨씬 흔하다. 알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 건다.
그럼 readiness 를 지표로 볼 수 있는지 물어본다.
```bash label="[kc-lab-1] 파드 readiness 지표가 있는지 묻는다"
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 만 보고 있으면 알 수 없고, 관측 스택에 빠진 것을 이 실험이 찾아냈다.
## 복구와 원상복구 확인표
### 1. 데이터베이스를 다시 켠다
**목적** — PostgreSQL 을 되살리고 Keycloak 이 스스로 돌아오는지 본다.
```bash label="[kc-lab-1] ① 시각을 남기고 replica 를 1 로 올린다"
date '+%H:%M:%S 재기동'
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
```
```bash label="[kc-lab-1] ② 롤아웃이 끝날 때까지 기다린다"
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
```
**예상 결과** — 실측은 이렇다(observed, `05-recovery.txt`).
```text
=== 복구 — 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
```
**왜 필요한가** — 여기서 Keycloak 을 재시작하고 싶어진다. 참는다. 재시작하면 이 실험이 답하려던 물음(「사람 개입이 필요한가」)이 사라진다.
**문제가 생기면** — 롤아웃이 타임아웃으로 끝나면 노드 상태와 PVC 를 본다.
### 2. Keycloak 이 재시작 없이 돌아오는지 15초 간격으로 본다
**목적** — 사람이 한 일이 DB 를 켠 것뿐인지 확인한다.
```bash label="[kc-lab-1] ① Ready 와 정문을 함께 친다"
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
```
```bash label="[kc-lab-1] ② 재시작 횟수만 따로 센다"
kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
```
**예상 결과** — 실측은 이렇다(observed).
```text
=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
→ 서비스 복귀
```
②의 실측은 `keycloak-0 0`, `keycloak-1 0` 이다(observed). 주입 검증에서 본 값 그대로다.
**왜 필요한가** — 커넥션 풀이 스스로 재연결하고 readiness 가 다시 UP 이 되면서 Service 에 복귀했다. 회복 시간은 DB Ready 이후 약 15초, Keycloak 재시작은 불필요(`restarts=0`)였다. liveness 는 실패하면 재시작이라 재시작하면 나아지는 문제(교착, 메모리 누수)에 쓰고, readiness 는 실패하면 트래픽에서 격리라 재시작해도 안 나아지는 문제(의존 대상이 죽음)에 쓴다. DB 장애에 liveness 를 걸면 모든 파드가 무한 재시작하고, DB 가 돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어지며, 재시작하면 캐시까지 날아간다.
**문제가 생기면** — 계속 `503` 이면 Keycloak 이 아직 재연결 중이다. 15~30초 더 기다리고 재시작하지 않는다.
### 3. 세션이 살아남았는지 세고 탐침 파드를 지운다
**목적** — 정상 종료가 데이터를 잃지 않았다는 것을 숫자로 확인하고 실험 도구를 치운다.
```bash label="[kc-lab-1] ① 세션 수를 다시 센다"
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"
```
```bash label="[kc-lab-1] ② 탐침 파드를 직접 지운다"
kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
```
**예상 결과** — 실측은 `online 세션 5` 다(observed). 주입 전에 적어 둔 값보다 크거나 같다. 실험 중에 로그인을 여러 번 했으므로 늘어나 있다.
**왜 필요한가** — 세션은 DB 에 있으므로 DB 가 돌아오면 같이 돌아오고, 정상 종료였기 때문에 하나도 잃지 않았다. 탐침 파드는 `sleep 7200` 이 끝나면 `Completed` 로 남고 자동으로 사라지지 않는다. 다음 실험에서 `a2-probe` 이름이 이미 있다고 거절당하는 까닭이 거기 있다.
**문제가 생기면** — 세션 수가 주입 전보다 적으면 이 실험 밖에서 누가 지운 것이다. A-3 의 정리 단계가 돌았는지 본다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| DB | `kubectl -n keycloak-lab get deploy postgres` | `1/1` |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running`, `RESTARTS 0` |
| Service | `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` |
| 탐침 파드 | `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` |
표는 위에서 아래로 한 번에 치지 않는다. 막혔을 때 해당하는 줄만 짚는다. 헬스 줄의 `$K0` 는 관찰 절에서 관찰용 터미널에 잡아 둔 값이라 그 터미널을 닫았으면 그 두 줄부터 다시 친다. 헬스 줄 자체는 탐침 파드가 남아 있을 때만 되므로 ② 로 지웠으면 건너뛴다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| 출력이 `HTTP 000000{...}401` 처럼 뭉쳐 나온다 | **`-o /dev/null` 을 뺐다.** 본문과 코드가 섞였다 | 오염된 측정은 버리고 다시 잰다 |
| `000` 이 앞에 붙어 나온다 | `--retry` 가 걸려 실패 시도의 코드까지 찍었다 | 재시도를 빼고 `--max-time` 만 쓴다 |
| DB 를 내렸는데 몇 초 만에 돌아온다 | **`delete pod` 를 썼다.** Deployment 가 새로 만든다 | `scale --replicas=0` |
| ④ 가 `401` 이다 | **access token 이 만료됐다** (수명 60초) | 토큰 발급 → 정지 → 시험을 60초 안에 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 치거나 Prometheus 에 묻는다 |
| 파드 셸에 다시 들어갔더니 변수가 없다 | `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| `up` 이 1 이라 정상인 줄 알았다 | **`up` 은 스크레이프 성공만 말한다** | readiness 와 외부 코드를 본다 |
| `kube_pod_status_ready` 결과가 비었다 | **`kube-state-metrics` 가 이 실험대에 없다** | 보완 항목이다. 지금은 `kubectl` 로 본다 |
| 복구했는데 계속 `503` | Keycloak 이 아직 재연결 중이다 | 15~30초 더 기다린다. **재시작하지 않는다** |
| `a2-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a2-probe --ignore-not-found` |
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a2-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 11:5311:58 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 정지 `11:56:04`·재기동 `11:57:09`, 네 경로 전부 `500`, JWKS 와 `.well-known` 이 `200`, `ready : []`, 정문 `503`, 헬스 네 항목 중 `database connections` 만 DOWN, `up` 이 양쪽 `1`, `kube_pod_status_ready` 결과가 빈 배열, `restarts=0`, 복구 세션 `5`, 비밀번호 길이 `19`.
- (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력 셋과 `CID` 를 뽑는 줄. 가이드가 미검증으로 표시했다.
- 한 번은 버린 측정이 있다 — ④ 의 첫 측정 `HTTP 000000{"error":"HTTP 401 Unauthorized"}401` 은 `-o /dev/null` 을 빼고 `--retry` 를 걸어 나온 오염된 값이라 쓰지 않았다. 표의 `500` 은 DB 가 내려가 있는 동안 서명 검증 경로와 함께 다시 잰 값이다.
- ④ 를 언제 다시 쟀는지를 원본과 다르게 적었다(unknown). 원본 가이드는 그 `500` 을 「복구 절에서 다시 잰 값」이라고 쓰는데 복구 절에는 ④ 를 다시 재는 명령이 없고, DB 가 선 뒤에 재면 `500` 이 나올 수 없다. 증거 파일 `04-health-and-service.txt` 의 「④ 다시」 절은 정문 `503` · `ready : []` 와 같은 묶음에 들어 있어 정지 구간에 찍혔다. 그래서 이 기록은 재측정을 관찰 절에 넣었고, 원본이 「복구 절」이라고 쓴 것이 무엇을 가리키는지는 확인하지 못했다.
- 말할 수 있는 범위가 여기까지인 것이 하나 있다 — JWKS 가 살아 있으므로 「이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다」는 이론이고, 이 실험대에 독립 리소스 서버가 없어 확인하지 못했다. B층에서 확인한다.
<!-- body:end -->
@@ -0,0 +1,789 @@
---
id: 91ce17ad-758d-4a63-be5b-489786609557
kind: SETUP
slug: reproduce-a3-database-crash
title: PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/91ce17ad-758d-4a63-be5b-489786609557/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-3
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다
PostgreSQL 을 진짜로 죽여 커밋됐다고 응답받은 로그인 중 몇 건이 사라지는지 세는 절차다. 절차의 절반은 죽이는 데 실패하는 두 가지 방법이고, 그래서 결과를 세기 전에 주입 성공 신호부터 본다. 전 구간 약 40분이고 터미널 두 개가 필요하다.
## 관계
- **200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다**
이 절차가 낸 결론을 담은 기록이다. 여기에는 치는 순서만 있다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
이 편의 시도 ① 과 ② 가 그 아홉 건에 들어간다. 둘 다 화면에는 성공으로 보였다.
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
파드 안에서 `( ... ) &` 로 띄운 루프가 `exec` 세션과 함께 죽어 0건을 모은 것을 다룬다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
주입 성공 신호를 미리 정해 두는 규칙을 편 기록이다. 이 편이 그 규칙이 없었으면 정반대 결론을 낼 뻔했다.
- **PostgreSQL 을 정상 종료시키고 네 경로를 잰다**
먼저 해 둬야 하는 편이다. 정상 종료가 아무것도 잃지 않는다는 그쪽 결과가 이 편의 대조군이다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 `kubectl``psql` 로 친다. 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl``sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
터미널은 둘이 반드시 필요하다. 터미널 ① 은 로그인 루프가 도는 동안 통째로 붙잡히고, 터미널 ② 에서 그 사이에 데이터베이스를 죽인다. 코드블록마다 어느 터미널인지 붙여 두었다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 주입 수단 | 셋을 순서대로 — `--grace-period=0 --force` · `kill -9 1` · 백엔드 SIGKILL |
| 주입 성공 신호 | `database system was not properly shut down``redo starts`·`redo done` |
| 탐침 파드 | `a3-probe``curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 손으로 쓰는 파일 | `/tmp/a3-login-loop.sh` — 400회 로그인 루프. 편집기로 쓴다 |
| 로그 시각 | PostgreSQL 로그 줄은 UTC 로 찍힌다. 친 명령의 시각은 KST 다 |
| 걸리는 시간 | 전 구간 약 40분 |
| 도구 | `jq` 가 이 실험대에 없다 |
## 이 실험이 가르는 것
A-2 는 데이터베이스를 정상 종료시켰고 세션은 하나도 안 없어졌다. PostgreSQL 은 SIGTERM 을 받으면 WAL 을 플러시하고 내려가기 때문이다. 그런데 A-0 에서 이 한 줄을 잡았다.
```sql label="A-0 의 문장 로그에서 COMMIT 직전에 나온 줄"
SET LOCAL synchronous_commit TO OFF
```
`COMMIT` 직전, 같은 트랜잭션 안에서 나온다.
```text
COMMIT
├─ WAL 버퍼(메모리)에 기록 ← 항상 한다
├─ synchronous_commit = on : 디스크 플러시를 기다렸다가 응답
└─ synchronous_commit = off : 기다리지 않고 즉시 응답 ← Keycloak
└─ 크래시 시 이 구간이 사라진다
```
「사라질 수 있다」와 「몇 건 사라졌다」는 다르다. 이 실험은 뒤쪽이고 RPO(Recovery Point Objective, 복구 시점 목표)를 숫자로 만든다.
A-0 을 끝내고 아직 아무것도 주입하기 전에 쓴 예측표에 이 실험이 「DB 강제 종료」로 올라 있다. 예측 칸은 「직전 수백 ms 의 세션 갱신이 사라진다」이고 근거 칸은 `synchronous_commit OFF` 다. 그 예측이 가리킨 것은 갱신 트랜잭션인데 이 절차가 세는 것은 로그인이라, 설계 확인 2번이 로그인 트랜잭션도 같은 설정을 거는지부터 본다.
그리고 이 실험의 절반은 죽이는 데 실패하는 이야기다. 세 번 시도해서 세 번째에 성공했고, 앞의 둘은 「손실 0건」으로 보였지만 실제로는 죽인 적이 없었다.
절차를 끝까지 밟으면 로그인 트랜잭션에 붙은 `SET LOCAL synchronous_commit TO OFF`, `--grace-period=0 --force` 가 크래시가 아니라는 것, 컨테이너 안에서 PID 1 이 SIGKILL 을 무시하는 것, `not properly shut down` 과 `redo starts`·`redo done`, `200` 과 토큰을 받았는데 DB 에 없는 sid, `wal_writer_delay = 200ms` 가 기본값이라는 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- **A-0 과 A-2 를 먼저 한다.** A-0 이 `SET LOCAL synchronous_commit TO OFF` 를 발견했고, 이 실험은 그 대가가 몇 건인지를 잰다.
- 세 파드가 전부 `1/1 Running` 이고 `RESTARTS` 가 `0` 이어야 한다. 그 값은 주입 판정에 쓰이므로 적어 둔다.
- A-0 이 켰던 문장 로깅이 꺼져 있어야 한다. 켜진 채로 루프를 돌리면 크래시 타이밍이 달라진다.
**이건 데이터를 잃는 실험이다.** PostgreSQL 을 강제로 죽이고 세션 테이블을 두 번 비운다. 실제로 커밋됐다고 응답한 데이터가 사라진다. **실험대에서만 한다.**
지운 세션은 돌아오지 않는다. 되돌릴 수 있는 것은 문장 로깅 하나이고, 켜기 전에 끄는 명령을 먼저 읽어 둔다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 묶음"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
```
## 주입 전에 같은 명령으로 먼저 본다
측정 설계가 성립하는지부터 본다. 여기서 하나라도 어긋나면 뒤의 숫자는 아무 의미가 없다.
### 1. 시각 컬럼의 눈금으로 무엇을 잴 수 있는가
**무엇을 보는가** — 세션 테이블의 컬럼 타입.
```bash label="[kc-lab-1] 세션 테이블의 정의를 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "\d offline_user_session"
```
**어디를 보나** — 실측은 이렇다(observed, `01-crash-injection.txt`).
```text
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초라 보일 리가 없고, 「세션 갱신 시각이 되감기는지」 보려던 설계는 버렸다. 대신 행 존재 여부로 잰다.
```text
로그인 1회 = OFFLINE_USER_SESSION 행 1개
클라이언트가 sid 를 받았다 = 서버가 COMMIT 했다고 응답했다
크래시 후 그 sid 가 없다 = 잃은 것
```
있거나 없거나이므로 눈금 문제가 없다. 이 실험이 로그인 수백 건을 도는 까닭이 여기 있다 — 이진 판정을 여러 번 해서 비율로 만든다.
### 2. 로그인 트랜잭션도 비동기 커밋인지 확인한다
**무엇을 보는가** — A-0 에서 잡은 것은 refresh 트랜잭션이었고 로그인(INSERT)도 그런지는 확인하지 않았다. 아니라면 로그인은 안 사라지고 이 측정 설계 자체가 성립하지 않는다.
```bash label="[kc-lab-1] ① 문장 로깅을 잠깐 켠다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
`none` 이면 `pg_reload_conf()` 가 안 돈 상태다. `alter system` 은 `postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.
```bash label="[kc-lab-1] ② 탐침 파드를 띄우고 Ready 까지 기다린다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a3-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a3-probe --timeout=120s
```
명령줄에 비밀번호를 직접 쓰지 않는다. 원래 실험의 재현 절차에는 평문 비밀번호가 그대로 적혀 있는데 파드 안 `ps` 에도 셸 히스토리에도 남는다. `--env` 로 넘긴 값은 그 파드 안에서만 산다. 존재와 길이만 확인한다 — 실측은 `19` 다(observed).
```bash label="[kc-lab-1] ③ 비밀번호의 길이만 두 곳에서 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
kubectl -n keycloak-lab exec a3-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
```
```bash label="[kc-lab-1] ④ 로그인 한 번을 보낸다"
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"'
```
```bash label="[kc-lab-1] ⑤ 그 로그인의 트랜잭션을 로그에서 본다"
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep -E 'BEGIN|insert into OFFLINE|synchronous_commit|COMMIT' | tail -20
```
**어디를 보나** — 실측은 이렇다(observed, `02-design-check.txt`).
```text
=== [설계 확인] 로그인 트랜잭션도 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 label="[kc-lab-1] ⑥ 문장 로깅을 끄고 꺼졌는지 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**켜 둔 채로 주입에 들어가면 안 된다.** 주입 단계는 수백 건의 로그인을 최대한 빨리 도는데, `log_statement='all'` 이면 로그인 하나에 SQL 열 몇 줄씩 쌓이고 로그가 폭주하며 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.
### 3. WAL 설정을 재기 전에 잰다
**무엇을 보는가** — 나중에 손실 창과 견줄 값.
```bash label="[kc-lab-1] 커밋과 WAL 관련 설정 네 줄"
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')"
```
**어디를 보나** — 실측은 이렇다(observed, `08-wal-settings.txt`).
```text
=== 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` 이다. 전역 설정만 보면 「우리는 동기 커밋」이라고 믿게 되는데 Keycloak 이 자기 트랜잭션에만 `SET LOCAL` 로 뒤집는다. 서버 설정만 보고 판단하면 틀린다. 원래 실험은 결과를 먼저 쓰고 「`wal_writer_delay` 기본값(200ms)과 맞는다」고 주장했는데 그 시점에 이 값을 조회한 적이 없었다. 나중에 재서 맞기는 했지만 그때는 추정이었다. 가정한 값은 재기 전에 재 둔다.
### 4. 세션 테이블을 비우고 0 인지 센다
**무엇을 보는가** — 출발값.
```bash label="[kc-lab-1] ① 세션 행을 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
```bash label="[kc-lab-1] ② 남은 행을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**어디를 보나** — 실측은 `05-true-crash.txt` 의 `DELETE 375` 와 `남은 세션: 0` 이다(observed).
**이 값이 뜻하는 것** — 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히 놓고 볼 것이므로 시작이 0 이어야 그 둘이 읽힌다. 캐시는 안 비워도 된다 — 이 실험의 판정은 DB 행의 존재 여부이고 캐시는 판정에 안 들어간다.
### 5. 파드 셋의 재시작 횟수를 적어 둔다
```bash label="[kc-lab-1] 파드 셋의 상태"
kubectl -n keycloak-lab get pods -o wide
```
`keycloak-0` `keycloak-1` `postgres` 가 전부 `1/1 Running` 이고 `RESTARTS` 가 `0` 이어야 한다. `RESTARTS` 값을 적어 둔다 — 주입 판정의 일부다.
## 주입
세 번 시도한다. 순서대로 따라가면 죽이는 데 실패하는 두 가지 방법을 직접 보게 되고, 건너뛰고 세 번째만 하면 왜 그것이 유일한 방법인지 모른다.
**시도 셋을 연달아 치지 않는다.** 하나를 주입할 때마다 아래 「주입 검증」의 같은 번호 절을 치고 다음 시도로 넘어간다. 셋이 같은 `/tmp/sids` 와 같은 DB 를 보기 때문에 몰아서 치면 `wc -l /tmp/sids` 가 세 시도의 합을 내고, 시도 ① 이 0건을 잃었다는 것을 더는 보일 수 없다. 치는 순서는 이렇다.
```text
시도 ① ─▶ 주입 검증 §1 ─▶ 시도 ② ─▶ 주입 검증 §2 ─▶ 시도 ③ ─▶ 주입 검증 §3
```
### 1. 로그인 루프를 파일로 써서 파드에 넣는다
**목적** — 클라이언트가 `200` 과 토큰을 실제로 받은 로그인의 목록을 파드 안 파일에 쌓는다.
루프는 한 줄로 칠 물건이 아니다. 원래 실행은 이걸 `kubectl exec ... sh -c "..."` 한 줄에 욱여넣었고 인용이 세 겹이 되어 두 번 깨졌다. 실측은 이렇다(observed, `01-crash-injection.txt`).
```text
=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
루프 시작
6초 경과 — 지금까지 성공한 로그인: 0
...
클라이언트가 200 을 받은 로그인 수: 0
```
0건이다. 파드 안에서 `( ... ) &` 로 띄운 루프가 `exec` 세션이 끝날 때 같이 죽었고 측정 자체가 없었다. 그래서 편집기로 파일을 연다.
```bash label="[kc-lab-1] ① 편집기로 루프 파일을 연다"
vim /tmp/a3-login-loop.sh
```
```sh label="/tmp/a3-login-loop.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 했다고 응답한 것」의 목록이고, 그게 이 실험의 시험군이다.
```bash label="[kc-lab-1] ② 파일을 파드 안으로 밀어 넣고 줄 수를 센다"
kubectl -n keycloak-lab exec -i a3-probe -- sh -c 'cat > /tmp/a3-login-loop.sh' \
< /tmp/a3-login-loop.sh
kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/a3-login-loop.sh
```
**예상 결과** — 줄 수가 `17 /tmp/a3-login-loop.sh` 로 나오면 들어갔다.
**왜 필요한가** — `kubectl cp` 도 되지만 컨테이너에 `tar` 가 있어야 한다. 이 실험대의 `curlimages/curl:8.11.1` 에 `tar` 가 있는지는 재지 않았다(unknown). 그래서 가이드는 `cat >` 로 밀어 넣는 쪽이 어디서나 통한다고 적고 그쪽을 골랐다.
**문제가 생기면** — 줄 수가 0 이면 로컬 파일이 비었거나 경로가 틀린 것이므로 `wc -l /tmp/a3-login-loop.sh` 를 로컬에서 먼저 친다.
### 2. 터미널 하나를 루프에 통째로 쓴다
**목적** — 초당 십몇 건의 로그인을 계속 보내면서 크래시 순간을 감싼다.
```bash label="[터미널 ①] 앞으로 두고 돌린다. 이 터미널은 붙잡힌다"
kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh
```
```bash label="[터미널 ②] 얼마나 쌓였는지 본다"
kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
```
**예상 결과** — 실측은 `06-backend-kill-crash.txt` 의 `8초 후: 112 건` 이다(observed). 8초에 112건이면 초당 약 14건이고, 이 속도를 적어 둔다 — 손실 건수를 시간으로 환산할 때 쓴다.
**왜 필요한가** — `&` 로 배경에 보내지 않는다. 그게 원래 실행이 실패한 까닭이고, 터미널을 하나 통째로 이 루프에 쓴다. 이 앞으로 두고 돌리는 형태는 미검증이다(unknown) — 원래 실행은 호스트에서 배경 `exec` 로 했다.
**문제가 생기면** — 0건이면 루프가 안 도는 것이므로 터미널 ① 을 본다.
### 3. 시도 ① — `--grace-period=0 --force`
**목적** — 「강제 삭제」라는 이름이 붙은 방법이 크래시인지 확인한다.
```bash label="[터미널 ②] 시각을 남기고 강제 삭제한다"
date '+%H:%M:%S.%3N 종료'
kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force
date '+%H:%M:%S.%3N 반환'
```
**예상 결과** — 실측은 이렇다(observed, `01-crash-injection.txt`).
```text
=== [2] PostgreSQL 강제 종료 (SIGKILL) ===
종료 시각: 12:00:26.511
pod "postgres-7b474b88c8-xc2vt" force deleted from keycloak-lab namespace
삭제 반환: 12:00:26.586
```
**왜 필요한가** — 이 시도를 건너뛰면 다음 절의 검증이 무엇을 가르는지 알 수 없다. 결과는 주입 검증에서 본다.
**문제가 생기면** — 파드가 새로 안 뜨면 `rollout status` 로 기다린다.
**다음** — 여기서 멈추고 「주입 검증」 §1 을 친 뒤 시도 ② 로 넘어온다.
### 4. 시도 ② — 컨테이너 안에서 `kill -9 1`
**목적** — postmaster 가 컨테이너의 PID 1 이므로 직접 SIGKILL 을 보내 본다.
```bash label="[터미널 ②] PID 1 에 SIGKILL 을 보낸다"
date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1
```
**예상 결과** — 명령은 조용히 끝난다. 무슨 일이 일어났는지는 주입 검증에서 본다.
**왜 필요한가** — 이것도 건너뛰면 세 번째 방법이 왜 유일한지 모른다.
**문제가 생기면** — 명령이 오류를 내면 파드 이름과 네임스페이스를 먼저 본다.
**다음** — 여기서 멈추고 「주입 검증」 §2 를 친 뒤 시도 ③ 으로 넘어온다.
### 5. 시도 ③ — 백엔드 프로세스를 죽인다
**목적** — postmaster 가 공유 메모리 오염을 보고 전체를 재초기화하게 만든다. PostgreSQL 은 postmaster(부모) + 연결마다 백엔드(자식) 구조이고, 자식 하나가 비정상 종료하면 postmaster 가 전체를 재초기화하며 그것이 곧 crash recovery 다.
```bash label="[터미널 ②] ① 무엇을 죽일지 눈으로 먼저 본다"
kubectl -n keycloak-lab exec deploy/postgres -- ps -ef | head -20
```
값은 환경마다 다르고 모양은 이렇다.
```text
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초쯤 쌓이면 터미널 ② 에서 죽인다.
**위 출력의 `40`·`41` 은 이 실험대에서 나온 값이다. 당신 화면의 숫자는 다르다** — 방금 친 `ps -ef` 가 보여 준 PID 를 읽어서 넣는다. 예시 숫자를 그대로 치면 그 파드의 엉뚱한 프로세스를 죽인다.
```bash label="[터미널 ②] ② 방금 본 PID 를 넣어 죽인다 — <PID> 를 바꿔서 친다"
date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- kill -9 <PID>
```
이 두 줄 형태는 이 실험대에서 치지 않았다(unknown). **실제로 친 것은 이름으로 고르는 쪽이다** — 백엔드가 여럿이면 이쪽이 한 번에 전부 끊는다.
```bash label="[터미널 ②] 이 실험대가 실제로 친 형태 (observed)"
kubectl exec deploy/postgres -- pkill -9 -f 'postgres: keycloak'
```
이 실험대는 한 줄에 원격 셸과 명령 치환을 겹쳐서 쳤다(observed).
```bash label="[터미널 ②] 원래 실행이 친 형태"
date '+%H:%M:%S.%3N SIGKILL'
kubectl -n keycloak-lab exec deploy/postgres -- \
sh -c 'kill -9 $(pgrep -f "postgres: keycloak keycloak" | head -1)'
```
**예상 결과** — 실측은 이렇다(observed, `06-backend-kill-crash.txt`).
```text
=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
시각: 12:04:22.063
최종 성공 로그인: 153 건
```
터미널 ① 의 루프를 `Ctrl-C` 로 멈춘다.
**왜 필요한가** — 컨테이너 밖이 아니라 안에서 보내는 시그널이라 PID 1 로는 통하지 않는다. 자식 프로세스라야 SIGKILL 이 전달된다.
**문제가 생기면** — `pgrep` 이 아무것도 못 찾으면 Keycloak 이 아직 연결을 안 만든 것이므로 `ps -ef` 로 먼저 본다.
**다음** — 「주입 검증」 §3 을 친다.
## 주입 검증
**세 절은 이어서 치는 것이 아니다.** 각각 같은 번호의 시도 직후에 치고 「주입」으로 돌아간다.
결과를 세기 전에 주입 성공 신호를 본다. 이 실험은 그 신호를 미리 정해 뒀다.
```text
PostgreSQL 이 정상 종료했다 → pg_control 에 "깨끗하게 종료됨" 표시
→ 다음 기동에 아무 말 없이 뜬다
PostgreSQL 이 즉사했다 → 표시가 없다
→ "database system was not properly shut down"
→ "redo starts at ..." / "redo done at ..."
```
### 1. 시도 ① 의 검증 — 죽인 적이 없다
```bash label="[kc-lab-1] 새 파드를 기다리고 기동 로그를 본다"
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept'
```
실측은 이렇다(observed, `02-design-check.txt`).
```text
=== 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 label="[kc-lab-1] 클라이언트가 받은 건수와 DB 건수를 나란히 본다"
kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
실측은 이렇다(observed, `04-comparison.txt`).
```text
=== [5] 전체 대조 — 몇 건이나 사라졌는가 ===
클라이언트 성공: 291 건
DB 에 존재: 291 건
★ 유실: 0 건
```
0건이다. 그런데 이건 안 잃은 것이 아니라 죽인 적이 없다. 시그널 셋이 다르게 동작한다 — SIGTERM 은 fast shutdown 으로 진행 중 트랜잭션을 롤백하고 WAL 을 플러시한 뒤 종료하고, SIGINT 는 smart shutdown 으로 연결이 끊기길 기다리며, SIGKILL 은 즉사라 플러시가 없고 다음 기동에 crash recovery 가 돈다. `--force --grace-period=0` 는 API 오브젝트를 즉시 지우고 거기서 끝난다. 컨테이너 런타임은 여전히 정상 종료 절차를 밟고 PostgreSQL 은 SIGTERM 을 받고 얌전히 플러시했다.
운영 함의가 여기 붙는다 — 장애 훈련이 훈련이 안 될 수 있다. 「강제 삭제로 DB 를 죽여 봤는데 아무 문제 없었다」는 결론은 아무것도 죽이지 않은 것일 수 있고, 훈련에는 주입 성공 신호가 있어야 한다.
### 2. 시도 ② 의 검증 — 아무 일도 일어나지 않았다
```bash label="[kc-lab-1] 파드와 기동 로그의 마지막 세 줄"
kubectl -n keycloak-lab get pods -l app=postgres
kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' | tail -3
```
실측은 이렇다(observed, `05-true-crash.txt`).
```text
=== [재주입] 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
```
두 가지를 같이 본다. `RESTARTS` 가 안 올랐으니 파드는 재시작하지 않았고, 로그의 마지막 줄 시각이 `02:59:48` 인데 시도 ① 때 뜬 그 시각 그대로다. **줄의 존재가 아니라 시각을 본다** — `ready to accept connections` 줄이 있다는 것으로 판정하면 안 된다.
까닭은 PID 1 의 시그널 보호다. 리눅스 커널은 PID 1 을 특별 취급해서 자기 PID 네임스페이스 안에서 온 시그널은 핸들러가 등록된 것만 전달하고 SIGKILL 도 예외가 아니다.
```text
같은 네임스페이스 안에서 → PID 1 은 등록하지 않은 시그널을 무시한다
조상 네임스페이스에서 → 전달된다 (노드에서 kill -9 하면 죽는다)
```
부팅 초기에 init 을 실수로 죽여 시스템이 멈추는 것을 막기 위한 장치인데, 컨테이너에서는 「안에서는 PID 1 을 못 죽인다」로 나타난다. 그래서 크래시 재현은 두 갈래다 — (a) 자식 프로세스를 죽이거나 (b) 노드에서 `ssh kc-lab-2 'sudo kill -9 <호스트 PID>'` 로 죽인다. 컨테이너 밖은 조상 네임스페이스이므로 SIGKILL 이 통한다. 이 실험은 (a) 로 했고 (b) 는 치지 않았다(unknown).
### 3. 시도 ③ 의 검증 — 이번엔 걸렸다
```bash label="[kc-lab-1] 크래시 흔적 여섯 줄을 한 번에 본다"
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'
```
실측은 이렇다(observed, `06-backend-kill-crash.txt`).
```text
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` 와 `redo done` 이 재생된 WAL 구간, `checkpoint complete` 가 재생 결과를 디스크에 고정한 것, 그리고 `ready to accept connections` 의 시각이 새로 찍혔다.
```bash label="[kc-lab-1] 파드 재시작 횟수를 센다"
kubectl -n keycloak-lab get pods -l app=postgres -o custom-columns=\
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount
```
`RESTARTS` 는 `0` 이다. 컨테이너의 PID 1 인 postmaster 는 살아 있고 자식만 갈아치웠다. 쿠버네티스 관점에서는 아무 일도 없었지만 데이터 관점에서는 전원이 나간 것과 같다.
## 관찰
```bash label="[kc-lab-1] ① 클라이언트가 받은 sid 목록을 꺼낸다"
kubectl -n keycloak-lab exec a3-probe -- cat /tmp/sids > /tmp/client-sids.txt
wc -l /tmp/client-sids.txt
head -3 /tmp/client-sids.txt
```
실측은 `07-loss-result.txt` 의 `클라이언트가 200 과 토큰을 받은 로그인 : 153 건` 이다(observed). 눈으로 한 번 보는 까닭은 빈 줄이 섞여 있으면 유실 건수가 부풀려지기 때문이다.
```text
CQUfg9HLH29xvhiu6pVlfWOo
5gLP4fqmpZBbjhH_d-0TPMMr
hkcOv1QskUFmYveMLB6Hljra
```
DB 쪽은 먼저 총계를 보고 그다음 목록으로 뽑는다.
```bash label="[kc-lab-1] ② 총계를 표로 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
```bash label="[kc-lab-1] ③ 값만 뽑아 파일로 내린다"
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
```
실측은 `DB 전체 온라인 세션 : 150 건` 이다(observed). `psql` 의 두 얼굴을 가른다 — `-c` 는 표를 그려서 사람이 읽기 좋고 `-tAc` 는 값만 줘서 파이프에 넣기 좋으므로, 한 번은 `-c` 로 눈으로 보고 셀 때만 `-tAc` 를 쓴다.
차집합은 `comm` 으로 낸다. 정렬부터 한다 — 이 세 줄은 미검증이다(unknown).
```bash label="[kc-lab-1] ④ 같은 정렬 순서로 맞춘 뒤 왼쪽 전용만 낸다"
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` 은 왼쪽 전용만 남긴다. **`LC_ALL=C` 를 빼면 안 된다** — `comm` 은 두 파일이 같은 정렬 순서임을 전제하는데 로케일이 다르면 대소문자와 기호 순서가 달라져 멀쩡한 sid 가 「없는 것」으로 잡힌다. sid 는 대소문자와 `-` `_` 가 섞인 base64url 이라 정확히 그 문제에 걸린다.
실측은 이렇다(observed, `07-loss-result.txt`).
```text
=== 크래시 전후 대조 ===
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
그중 DB 에 실제로 존재 : 149 건
★ 유실 : 4 건
=== 유실된 sid 목록 ===
★ CQUfg9HLH29xvhiu6pVlfWOo ← 토큰은 발급됐는데 세션이 없다
★ 5gLP4fqmpZBbjhH_d-0TPMMr ← 토큰은 발급됐는데 세션이 없다
★ hkcOv1QskUFmYveMLB6Hljra ← 토큰은 발급됐는데 세션이 없다
★ p5XybeQIYmAs818gO4Vl_5ea ← 토큰은 발급됐는데 세션이 없다
```
로그인이 성공했다고 응답받았는데 세션이 존재하지 않는다. 153건 중 4건, 약 2.6% 다.
```bash label="[kc-lab-1] ⑤ 유실 건수를 센다"
comm -23 /tmp/a.txt /tmp/b.txt | wc -l
```
사라지지 않은 것도 하나 본다. sid 는 첫 줄 `tail -1` 이 화면에 찍은 그 값을 옮겨 넣는다 — 로그인 루프가 만든 값이라 실행마다 다르다.
```bash label="[kc-lab-1] ⑥ 마지막 sid 가 DB 에 있는지 본다"
tail -1 /tmp/client-sids.txt
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='{{SID}}'"
```
실측은 이렇다(observed).
```text
=== 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) ===
마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
8do0Bw6tkVLDVxgxotE7GosH | 1788490958 | 1788490958
(1 row)
```
대부분은 멀쩡하다. 그래서 손실이 잘 안 보인다.
숫자를 읽을 때 성급하게 결론을 붙이지 않는다. 원래 문서는 「초당 19건 … `wal_writer_delay` 기본값(200ms)과 맞는다」고 썼는데 그 시점에 `wal_writer_delay` 를 조회한 적이 없었고 로그인 속도도 틀렸다. 증거를 다시 읽으면 8초에 112건 ≈ 초당 14건이고 4건 ≈ 약 0.29초 분량이다.
| | |
|---|---|
| 측정한 손실 | 4건 ≈ **약 0.29초 분량** |
| `wal_writer_delay` (주입 전에 잰 값) | **200 ms** |
| 관계 | **같은 자릿수이되 정확히 일치하지는 않는다** |
「같은 자릿수」까지가 이 실험이 말할 수 있는 범위다. `wal_writer_delay` 하나가 손실 창을 정하는 것도 아니고 `wal_writer_flush_after`(128 × 8kB)와 체크포인트 타이밍이 함께 작용한다. 재현하면 로그인 속도와 디스크와 죽인 순간이 전부 다르므로 중요한 값은 「4」가 아니라 「0 이 아니다」이고, 그 크기가 WAL 플러시 주기와 같은 자릿수다.
사용자에게는 이렇게 보인다.
```text
로그인 성공 → access token + refresh token 을 받음
│ (크래시)
다음 요청 → access token 은 60초간 통한다
│ (서명만 보는 경로라면)
60초 후 refresh → "Session not active" → 다시 로그인
```
즉시 드러나지 않는다. access token 수명 동안은 정상으로 보이다가 갱신 시점에 끊기므로 장애와 증상 사이에 최대 60초의 시차가 있다. 그래서 모니터링은 갱신 실패율을 본다 — 로그인 성공률만 보면 이 장애는 안 보인다. 로그인은 `200` 을 줬기 때문이다.
이 손실이 허용된 까닭은 세션 쓰기가 매우 잦고(로그인마다, refresh 마다), 잃어도 사용자가 다시 로그인하면 되며, 동기 커밋의 비용은 모든 요청에 붙는데 크래시는 드물기 때문이다. 드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택이고, 합리적이지만 선택했다는 사실을 알고 있어야 한다.
바꿀 수 있는지도 답이 있다.
```sql label="세션 트랜잭션까지 동기 커밋으로 강제하려는 시도 — 이것으로는 못 막는다"
-- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다)
ALTER DATABASE keycloak SET synchronous_commit = on;
```
`SET LOCAL` 이 우선하므로 이것으로는 못 막는다. Keycloak 설정이나 소스 수준의 문제이고, RPO 0 이 필요하면 복제(streaming replication)로 푸는 쪽이 맞다.
## 복구와 원상복구 확인표
### 1. 문장 로깅이 꺼져 있는지 본다
**목적** — 다음 실험의 측정값이 로그 폭주 때문에 달라지지 않게 한다.
```bash label="[kc-lab-1] ① 두 설정을 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement" -c "show log_line_prefix"
```
```bash label="[kc-lab-1] ② none 이 아니면 되돌리고 reload 한다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
```
**예상 결과** — `log_statement` 가 `none` 이다.
**왜 필요한가** — 이 편은 설계 확인 단계에서 문장 로깅을 한 번 켰다. 끄지 않고 넘어가면 다음 실험의 로그가 폭주하고, 로그인 루프를 도는 편에서는 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.
**문제가 생기면** — `pg_reload_conf()` 를 다시 친다. `alter system` 만으로는 적용되지 않는다.
### 2. 세션을 정리하고 파드를 재시작하고 탐침을 지운다
**목적** — DB 행과 캐시 엔트리를 함께 비우고 실험 도구를 치운다.
```bash label="[kc-lab-1] ① 세션 행을 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
```bash label="[kc-lab-1] ② 파드를 갈아 끼워 캐시를 비운다"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
```bash label="[kc-lab-1] ③ 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found
```
**예상 결과** — 두 Keycloak 파드가 새로 뜨고 `a3-probe` 가 `NotFound` 가 된다.
**왜 필요한가** — 재시작을 빼면 DB 만 지워지고 캐시 엔트리가 남아 캐시 합계와 DB 총계가 어긋난다. A-0 이 겪은 함정이고 다음 실험의 출발값을 망친다. 탐침 파드는 `sleep 7200` 이 끝나면 `Completed` 로 남고 자동으로 사라지지 않는다.
**문제가 생기면** — `a3-probe` 를 다시 못 만들면 옛 파드가 남아 있는 것이므로 `--ignore-not-found` 를 붙여 다시 지운다.
### 3. 데이터베이스가 건강한지 본다
**목적** — 크래시가 데이터 일부 손실인지 DB 파손인지 가른다.
```bash label="[kc-lab-1] ① 파드 상태"
kubectl -n keycloak-lab get pods -l app=postgres
```
```bash label="[kc-lab-1] ② 마이그레이션 이력 세 줄"
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 파손이 아니다.
**문제가 생기면** — 질의가 실패하면 파드 로그에서 기동 실패 원인을 본다.
### 4. 로컬 임시 파일을 치운다
**목적** — 다음 실행이 옛 sid 목록을 읽지 않게 한다.
```bash label="[kc-lab-1] 이 실험이 만든 로컬 파일 다섯 개"
rm -f /tmp/client-sids.txt /tmp/db-sids.txt /tmp/a.txt /tmp/b.txt /tmp/a3-login-loop.sh
```
**예상 결과** — 아무것도 출력되지 않는다.
**왜 필요한가** — `comm` 이 읽는 두 파일이 옛 실행의 값이면 유실 건수가 통째로 틀린다.
**문제가 생기면** — 지워지지 않았으면 `ls -l /tmp` 로 경로를 다시 본다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| DB | `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) |
| 파드 | `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` |
| 탐침 파드 | `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` |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| 유실이 `0건` 이다 | **죽인 적이 없다.** 대개 `--force` 나 `kill -9 1` 을 썼다 | `not properly shut down` 이 로그에 있나 |
| `ready to accept connections` 가 있으니 크래시인 줄 알았다 | **아까 뜰 때 찍힌 줄이다** | **줄의 존재가 아니라 시각**을 본다 |
| `kill -9 1` 을 했는데 아무 일도 없다 | **컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다** | 백엔드 프로세스를 죽인다 |
| 루프가 `0건` 을 모았다 | **파드 안에서 `&` 로 띄우면 `exec` 종료와 같이 죽는다** | 터미널 하나를 루프에 통째로 쓴다 |
| 로그가 폭주하고 크래시 타이밍이 이상하다 | **`log_statement='all'` 을 켠 채로 루프를 돌렸다** | `show log_statement` 가 `none` 인지 |
| 멀쩡한 sid 가 「없음」으로 잡힌다 | **`comm` 두 파일의 정렬 순서가 다르다** | `LC_ALL=C sort` 를 양쪽에 |
| 유실 건수가 부풀려진다 | `/tmp/sids` 에 빈 줄이 섞였다 | `head -3` 으로 눈으로 본다 |
| `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` |
| `pgrep` 이 아무것도 못 찾는다 | Keycloak 이 아직 연결을 안 만들었다 | `ps -ef` 로 먼저 본다 |
| 손실 건수를 시간으로 환산했더니 문서와 다르다 | **원래 문서가 속도를 잘못 썼다가 정정했다** | 초당 14건이 실측이다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 11:5812:05 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) `offline_user_session` 의 시각 컬럼이 `integer`, 로그인 트랜잭션에도 붙은 `SET LOCAL synchronous_commit TO OFF`, WAL 설정 네 줄(`wal_writer_delay 200 ms default` 포함), 시도 ①·②·③ 의 시각 `12:00:26.511`·`12:03:21.441`·`12:04:22.063`, crash recovery 로그 여섯 줄, `153 / 149 / 4`, 유실 sid 네 개, 초당 14건, `DELETE 375`, 비밀번호 길이 `19`.
- (unknown) `LC_ALL=C sort` 와 `comm -23` 세 줄, 루프를 앞으로 두고 돌리는 형태(원래 실행은 호스트에서 배경 `exec` 로 했다), `kubectl exec ... kill -9 40` 으로 PID 를 옮겨 적는 형태, 노드에서 호스트 PID 를 죽이는 (b) 갈래, `curlimages/curl:8.11.1` 에 `tar` 가 있는지.
- 이 실험이 두 번 틀렸다가 고친 것 — 「`--force` 로 죽였다」와 「`kill -9 1` 로 죽였다」가 둘 다 유실 0건이라는 깨끗한 결과를 냈다. 주입 성공 신호를 미리 정해 두지 않았다면 결론은 「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」가 됐을 것이다.
- 추정이었다가 나중에 잰 값 — `wal_writer_delay` 200ms. 원래 문서는 결과를 먼저 쓰고 그 값과 맞는다고 주장했는데 그때는 조회한 적이 없었고, 로그인 속도도 초당 19건으로 잘못 적었다가 14건으로 정정했다.
<!-- body:end -->
@@ -0,0 +1,750 @@
---
id: d845adc8-be2c-4471-aa1d-7e4db864c471
kind: SETUP
slug: reproduce-a4-node-loss
title: 기계 전원을 뽑고 쿠버네티스가 알아채는 시각을 잰다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/d845adc8-be2c-4471-aa1d-7e4db864c471/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-4
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 기계 전원을 뽑고 쿠버네티스가 알아채는 시각을 잰다
워커 노드와 k3s 서버를 차례로 `virsh destroy` 로 끄고 노드 이탈 인지와 축출 시작과 재배치 실패 시각을 재는 절차다. 주입이 하이퍼바이저 명령이라 되돌리기도 물리적이고 터미널을 세 개 쓴다. 전 구간 약 40분이고 실험대에서만 친다.
## 관계
- **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것**
이 절차가 재현하는 사건이고, 4a 와 4b 가 왜 다른 고장인지는 그 기록이 결론으로 갖는다.
- **장애 시간의 대부분은 알아채는 데 걸린다**
40초와 5분 40초를 차단 시각에서 빼서 재는 순서가 그 기준이 선 근거다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
노드가 통째로 없어지는 이 절차에서는 `up` 이 정확히 0 을 낸다. DB 만 잃었을 때와 왜 다른지를 그 기록이 설명한다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
꺼진 기계의 파드가 `ready=true` 로 얼어 있고 살아 있는 파드가 `ready=false` 인 상태를 이 절차가 만든다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령을 치는 곳이 세 군데이고, 그 구별이 이 절차의 내용이다. 코드블록마다 어느 터미널인지 붙여 두었다.
| 터미널 | 어디서 치나 | 무엇을 치나 |
|---|---|---|
| A | `test-server` (VM 호스트) | `virsh` — 전원을 뽑고 다시 넣는다 |
| B | `kc-lab-1` | `kubectl` — 관찰. 4b 에서는 이 터미널이 죽는다 |
| C | `test-server` | 밖에서 `curl`. 사용자 시점 |
4b 에서는 `kc-lab-2` 에도 붙는다. 터미널 A 와 같은 기계에서 `ssh kc-lab-2` 로 붙고, 이름이 안 풀리면 `ssh 192.168.122.12` 다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 대상 | 게스트 VM 둘 — `kc-lab-2`(워커) 와 `kc-lab-1`(k3s 서버) |
| 주입 | `virsh destroy`. `virsh shutdown` 을 쓰면 이 절차가 아니다 |
| 외부 확인 | 모든 `curl``--max-time 8` |
| 걸리는 시간 | 전 구간 약 40분. 4a 의 축출을 보는 데만 7분 |
| 되돌리기 | `virsh start`. `--grace-period=0 --force` 로 파드를 지우지 않는다 |
## 이 실험이 가르는 것
A-1 과 A-5 는 네트워크만 끊는다. 파드는 살아 있고 쿠버네티스는 계속 정확한 상태를 안다. 여기서는 기계 자체를 없애므로 상태를 보고할 주체가 사라진다.
A층은 예측을 먼저 적어 두고 주입했다. A-0 의 예측표는 이 실험을 옛 로드맵 번호인 `A-3 노드 상실 (kc-lab-2)` 로 적고 「세션은 살아남는다. 죽은 노드의 캐시만 사라진다」를 룩어사이드 캐시에서 끌어냈다. 그 한 줄에 postgres 는 나오지 않는다.
판본은 둘이고, 어느 노드를 끄느냐가 결과를 전부 바꾼다.
| 판본 | 끄는 노드 | 그 노드에 있는 것 | 무엇을 묻나 |
|---|---|---|---|
| 4a | `kc-lab-2` (워커) | keycloak-0 · postgres · postgres PVC | Keycloak 과 DB 를 동시에 잃으면 |
| 4b | `kc-lab-1` (k3s 서버) | keycloak-1 · Traefik · 컨트롤 플레인 · 관측 스택 | 들어갈 문을 잃으면 |
끝나면 셋을 말할 수 있다.
```text
쿠버네티스는 언제 알아채는가 → 40초 (그동안 거짓말을 한다)
무엇을 스스로 고치는가 → 축출. 단 5분 뒤
무엇을 못 고치는가 → PVC 가 묶인 재배치, StatefulSet 이름
```
## 전제와 되돌리기
- `virsh` 가 시스템 하이퍼바이저를 보고 있어야 한다. `qemu:///system` 이 아니면 VM 이 안 보인다.
- 4b 에서는 `kc-lab-2` 에도 붙는다. 터미널 A 와 같은 기계에서 `ssh kc-lab-2` 로 붙고, 이름이 안 풀리면 `ssh 192.168.122.12` 다.
- 4a 의 확인표를 통과하기 전에 4b 로 넘어가지 않는다. 두 고장이 겹치면 무엇이 원인인지 못 가린다.
### 1. virsh 가 시스템 하이퍼바이저를 보게 맞춘다
**목적** — 터미널 A 의 `virsh``qemu:///system` 을 보게 한다.
```bash label="[터미널 A] ① 연결 URI 를 이 셸에 건다"
export LIBVIRT_DEFAULT_URI=qemu:///system
```
```bash label="[터미널 A] ② 무엇을 보고 있는지 확인한다"
virsh uri
```
**예상 결과** — `qemu:///system` 한 줄.
**왜 필요한가** — `qemu:///session` 과 `qemu:///system` 은 서로 다른 libvirt 연결이다. VM 은 system 쪽에 있으므로 session 을 보고 있으면 목록 자체가 비어 나오고, 그것을 「VM 이 죽었다」로 읽게 된다.
**문제가 생기면** — 다음 단계에서 `virsh list --all` 이 빈 목록을 내면 여기부터 다시 본다.
:::warning
`virsh destroy` 는 종료 신호를 보내지 않는다. 전원 코드를 뽑는 것과 같아서 게스트 파일시스템이 더러운 채로 멈춘다. 실험대에서만 한다.
:::
어느 단계에서든 그만두려면 터미널 A 에서 한 줄이면 된다.
```bash label="[터미널 A] 중단 — 두 VM 을 다시 켠다"
virsh start kc-lab-2 ; virsh start kc-lab-1
```
## 주입 전에 같은 명령으로 먼저 본다
주입 뒤에 치는 명령과 같은 명령을 먼저 친다. 순서는 이렇다.
```text
VM → 노드 → 파드 배치 → 볼륨이 어디 묶여 있나 → 외부 응답 → 관측자가 어디 있나
```
### 1. VM 전원과 노드와 파드 배치
**무엇을 확인하는가** — 두 게스트가 켜져 있고 어느 파드가 어느 노드에 있는지.
```bash label="[터미널 A] ① VM 전원"
virsh list --all
```
```bash label="[터미널 B] ② 노드와 파드 배치"
kubectl get nodes
```
```bash label="[터미널 B] ③ 파드가 어느 노드에 있나"
kubectl -n keycloak-lab get pods -o wide
```
**출력에서 답이 되는 것** — `virsh list --all` 의 상태 열과 `get pods -o wide` 의 `NODE` 열이다. 실측은 이렇다.
```text
--------------------------
1 kc-lab-1 running
2 kc-lab-2 running
```
```text
kc-lab-1 Ready true
kc-lab-2 Ready <none>
a2-probe true kc-lab-2
keycloak-0 true kc-lab-2
keycloak-1 true kc-lab-1
postgres-7b474b88c8-2gf27 true kc-lab-2
```
**이 결과가 뜻하는 것** — `kc-lab-2` 에 keycloak-0 과 postgres 가 함께 있으므로 4a 는 Keycloak 한 대를 잃는 절차가 아니라 Keycloak 한 대와 DB 를 동시에 잃는 절차다. `virsh list --all` 앞의 숫자는 도메인 ID 이고 VM 을 껐다 켜면 바뀌므로 이름으로 다룬다. `a2-probe` 는 A-2 에서 띄우고 안 지운 상주 파드라 없어도 지장이 없다.
### 2. 볼륨이 어느 노드에 못박혀 있나
**무엇을 확인하는가** — postgres PVC(PersistentVolumeClaim, 영구 볼륨 요청) 가 요구하는 노드. 이 값이 뒤의 재배치 결과를 미리 정한다.
```bash label="[터미널 B] ① PVC 목록"
kubectl -n keycloak-lab get pvc
```
```bash label="[터미널 B] ② PV 이름"
kubectl -n keycloak-lab get pvc postgres-data -o jsonpath='{.spec.volumeName}' ; echo
```
```bash label="[터미널 B] ③ 그 PV 가 요구하는 노드 — 읽는 형태"
kubectl describe pv $(kubectl -n keycloak-lab get pvc postgres-data \
-o jsonpath='{.spec.volumeName}') | grep -A6 'Node Affinity'
```
**출력에서 답이 되는 것** — `Required Terms` 아래 호스트 이름이다. 값은 환경마다 다르다.
```text
Node Affinity:
Required Terms:
Term 0: kubernetes.io/hostname in [kc-lab-2]
```
값만 여러 번 비교할 때는 뽑는 형태로 줄인다.
```bash label="[터미널 B] ④ 같은 값을 한 줄로 — 뽑는 형태"
kubectl get pv $(kubectl -n keycloak-lab get pvc postgres-data \
-o jsonpath='{.spec.volumeName}') \
-o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' ; echo
```
**이 결과가 뜻하는 것** — 이 실험대에서 `postgres-data` 는 `kc-lab-2` 를 요구했다. `local-path` PVC 는 그 노드의 로컬 디렉터리(`/var/lib/rancher/k3s/storage/...`)이므로 노드가 죽으면 볼륨도 같이 죽고, 스케줄러는 `nodeAffinity` 로 그것을 알고 있어 다른 노드에 파드를 만들지 않는다. 결함이 아니라 이 실험대의 조건이다.
### 3. 밖에서 보이는 상태와 관측자의 위치
**무엇을 확인하는가** — 정문이 지금 무엇을 답하는지, 그리고 Prometheus 와 Grafana 가 어느 노드에 있는지.
```bash label="[터미널 C] ① 응답을 통째로 읽는 형태"
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
```
```bash label="[터미널 C] ② 여러 번 비교할 것이므로 코드만 뽑는 형태"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
```bash label="[터미널 B] ③ 관측 스택이 어느 노드에 있나"
kubectl -n observability get pods -o wide
```
**출력에서 답이 되는 것** — ② 는 `200`, ③ 은 `grafana` 와 `prometheus` 의 `NODE` 열이다. 이 실험대에서는 둘 다 `kc-lab-1` 에 있었다.
**이 결과가 뜻하는 것** — 4a 는 `kc-lab-2` 를 끄므로 Prometheus 가 살아남아 관측이 정확하고, 4b 는 관측자가 같이 죽는다. 미리 알아 두지 않으면 나중에 그래프의 빈 구간을 값 0 으로 읽는다. `--max-time` 은 모든 외부 확인에 준다. 4b 에서 그 값이 없으면 `curl` 이 몇 분씩 매달리고, 타임아웃이 곧 결과다.
## 주입
**주입은 둘이고 한 번에 하나씩만 건다.** 4a 를 끝까지 밟고 복구 확인표를 통과한 뒤에 4b 를 건다. 두 노드가 동시에 꺼져 있으면 무엇이 무엇의 결과인지 가릴 수 없고, 4b 는 API 서버를 끄기 때문에 4a 를 관찰할 `kubectl` 자체가 없어진다. 치는 순서는 이렇다.
```text
4a 주입 ─▶ 주입 검증 §1·§2 ─▶ 관찰 §1~§7 ─▶ 복구 §1 (확인표 통과)
┌─────────────────────────────────┘
4b 주입 ─▶ 주입 검증 §3 ─▶ 관찰 §8·§9 ─▶ 복구 §2
```
### 1. 워커 노드의 전원을 뽑는다 (4a)
**목적** — `kc-lab-2` 를 신호 없이 정지시켜 keycloak-0 과 postgres 를 동시에 잃는다.
```bash label="[터미널 A] ① 차단 시각을 먼저 찍는다"
date '+%H:%M:%S 차단'
```
```bash label="[터미널 A] ② 전원을 뽑는다"
virsh destroy kc-lab-2
```
**예상 결과**
```text
차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed
```
**왜 필요한가** — `virsh shutdown` 은 ACPI(Advanced Configuration and Power Interface, 전원 관리 규격) 종료 신호를 보내 kubelet 이 정상 종료하고 파드가 정리되므로, 쿠버네티스가 정상적인 노드 이탈로 처리해 이 절차의 발견 둘이 통째로 안 나온다. `virsh destroy` 는 신호가 없어 마지막 상태가 그대로 얼어붙는다. 시각을 찍는 것도 같은 이유다 — 40초와 5분은 이 시각에서 뺀 값이고, 기준점이 없으면 뒤의 관찰은 나열로 끝난다.
**문제가 생기면** — `virsh` 가 도메인을 못 찾으면 `qemu:///session` 을 보고 있는 것이므로 전제의 1번으로 돌아가 `virsh uri` 를 본다.
### 2. 컨트롤 플레인 노드의 전원을 뽑는다 (4b) — 4a 를 끝낸 뒤에 친다
**언제 치는가** — 복구 §1 의 원상복구 확인표를 전부 통과한 뒤다. 4a 의 주입 검증·관찰·복구를 먼저 밟고 여기로 온다.
**목적** — `kc-lab-1` 을 정지시켜 API 서버와 Traefik 과 관측 스택을 한꺼번에 잃는다.
```bash label="[터미널 B] ① 이 노드에 무엇이 있는지 — 이것이 곧 영향 범위다"
kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1
```
```bash label="[터미널 B] ② 진입점의 복제본 수"
kubectl -n kube-system get deploy traefik
```
```bash label="[터미널 A] ③ 차단 시각"
date '+%H:%M:%S 차단'
```
```bash label="[터미널 A] ④ 전원을 뽑는다"
virsh destroy kc-lab-1
```
**예상 결과** — ① 의 실측은 이렇다.
```text
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
```
④ 뒤에는 `차단 시각: 12:18:08` 과 `Domain 'kc-lab-1' destroyed` 가 나오고, 터미널 B 의 SSH 세션이 그대로 끊긴다.
**왜 필요한가** — `traefik` 이 이 노드에 있고 `replicas` 가 `1` 이라, 이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다. 인벤토리를 먼저 뽑아 두지 않으면 4b 에서 무엇이 없어졌는지 사후에 못 센다.
**문제가 생기면** — 4a 의 확인표를 통과하기 전에는 여기로 오지 않는다. 두 고장이 겹치면 무엇이 원인인지 못 가린다.
**다음** — 아래 「주입 검증」 §3 으로 간다. §1 과 §2 는 4a 의 것이라 이미 쳤다.
## 주입 검증
A-5 와 A-6 에서는 규칙을 넣었는데 카운터가 0 인 것이 실패였다. 여기서 검증하는 대상은 다르다. 믿을 수 있는 것은 하이퍼바이저이고, 쿠버네티스가 뭐라고 하든 그것은 결과다.
### 1. 기계가 실제로 꺼졌나 (4a)
**무엇을 확인하는가** — 게스트의 전원 상태.
```bash label="[터미널 A] ① 하이퍼바이저에게 묻는다"
virsh list --all
```
```bash label="[터미널 A] ② 미검증 — 원 실행에는 이 확인이 없다"
ping -c 2 -W 2 192.168.122.12
```
**출력에서 답이 되는 것** — ① 에서 `shut off` 이면 꺼진 것이고, ID 가 `-` 로 바뀐 것도 같은 말이다. ② 에서 `0 received` 가 나오면 꺼져 있다.
**이 결과가 뜻하는 것** — 여기까지가 주입 검증이다. 다음 단계의 `kubectl` 출력은 검증이 아니라 관측 대상이다.
### 2. 쿠버네티스는 아직 Ready 라고 말한다 (4a)
**무엇을 확인하는가** — 노드 상태와 사용자가 겪는 응답을 같은 시각에 나란히 본다.
```bash label="[터미널 B] ① 15초 간격으로 몇 번 친다"
kubectl get node kc-lab-2
```
```bash label="[터미널 C] ② 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
손이 아프면 한 줄로 묶는 형태가 있고, 그 루프는 미검증이다. `Ctrl-C` 로 멈춘다.
```bash label="[터미널 B] ③ 미검증 — 두 줄을 하나로 묶는다"
while true; do
printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \
"$(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
```
**출력에서 답이 되는 것** — 노드 상태가 넘어가는 줄과, 그 앞뒤의 외부 코드다. 실측은 이렇다.
```text
+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` 이 그것을 말한다. 여기서 주입이 안 걸렸다고 결론 내리면 틀린다 — 기계는 꺼져 있고 쿠버네티스가 아직 모를 뿐이다.
처음 40초가 `503` 이 아니라 `000` 인 까닭은 층이 다르기 때문이다. `000` 은 `curl` 이 응답 자체를 못 받은 것(타임아웃 또는 연결 실패)이고, `503` 은 nginx 와 Traefik 은 살아 있고 뒤로 보낼 파드가 없는 것이다. 엣지 nginx(`kc-lab-edge`)의 upstream 에는 두 노드가 다 들어 있다.
```text
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
```
죽은 쪽으로 배분된 요청은 응답도 거절도 못 받고 `--max-time 8` 에 걸린다. 이 까닭을 nginx 로그로 확인하려던 절이 증거 파일에 제목만 있고 아래가 비어 있다. 직접 볼 수 있는 줄을 가이드가 미검증으로 표시했다. `upstream timed out` 이 `192.168.122.12` 에 대해 찍히면 그것이 답이고, nginx 에러 로그는 2048바이트에서 잘리므로 잘려 보이면 access 로그를 본다.
```bash label="[터미널 C] 미검증 — 엣지 게스트의 에러 로그"
sudo tail -f /var/log/nginx/error.log
```
### 3. 4b 의 검증은 kubectl 이 죽은 것 자체다
**무엇을 확인하는가** — API 서버에 닿는지.
```bash label="[터미널 B] ① 붙어 있던 그 터미널에서"
kubectl get nodes
```
**출력에서 답이 되는 것** — `kubectl: Unable to connect to the server: dial tcp`.
**이 결과가 뜻하는 것** — API 서버가 `kc-lab-1:6443` 에 있었으므로 당연한 결과다. 4a 에서는 쿠버네티스가 뭐라고 하는가를 물을 수 있었고 여기서는 물어볼 상대가 없어, 이 절차의 관찰 도구가 통째로 바뀐다.
## 관찰
### 1. 죽은 파드가 산 파드보다 건강해 보인다
```bash label="[터미널 B] ① 파드마다 phase 와 ready 와 노드를 함께"
kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,PHASE:.status.phase,READY:.status.containerStatuses[0].ready,NODE:.spec.nodeName
```
```text
a2-probe Running true kc-lab-2 <none>
keycloak-0 Running true kc-lab-2 <none>
keycloak-1 Running false kc-lab-1 <none>
postgres-7b474b88c8-2gf27 Running true kc-lab-2 <none>
```
`keycloak-0` 은 꺼진 기계 위에서 `ready=true` 이고 `keycloak-1` 은 살아 있는데 `ready=false` 다. `keycloak-0` 은 kubelet 이 없어 상태를 갱신할 수 없으므로 마지막으로 보고한 값이 얼어 있고, `keycloak-1` 은 살아서 정직하게 보고한다 — DB 가 없으니 readiness 실패다. 파드 상태는 지금 어떤가가 아니라 마지막으로 그렇게 들었다이고, 노드가 죽으면 그 노드 파드의 상태는 갱신을 멈춘 값이 된다.
### 2. 이벤트는 Age 로 먼저 거른다
```bash label="[터미널 B] ① 최근 이벤트 스무 줄"
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
```
```text
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
```
노드를 뽑은 것은 `3m15s` 전인데 맨 위 줄은 `10m` 짜리라 주입보다 앞선 사건이고 앞 실험이 남겼다. 이벤트 목록은 시간대가 섞여 있으므로 `Age` 로 먼저 걸러야 내가 만든 일을 고를 수 있다. 주입 이후 `keycloak-0` 에 붙은 이벤트는 `NodeNotReady` 하나뿐인데 그것은 컨트롤러가 쓴 것이고, kubelet 이 없으니 그 파드에 대해 말해 줄 주체가 없다. `keycloak-1` 의 실패는 두 종류다 — 처음에는 프로브 자체가 타임아웃되고(`context deadline exceeded`), 나중에는 `503` 을 받는다. Keycloak 이 DB 없음을 스스로 판단해 답할 수 있게 된 것이라 같은 `Unhealthy` 라도 층이 다르다.
### 3. Prometheus 는 정확했다
```bash label="[터미널 B] ① 한 줄짜리 JSON 을 처음 한 번은 그대로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up'
```
```bash label="[터미널 B] ② 미검증 — 라벨만 남겨 자르는 형태"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up' \
| tr ',' '\n' | grep -E '"job":|"pod":|"node":|^"[0-9]'
```
```text
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 이 두 줄인 것은 노드마다 하나씩이기 때문이다. A-2 와 정반대로, 대상이 사라진 노드 상실은 `up` 이 잡고 대상이 살아서 못 쓰는 DB 상실은 못 잡는다.
### 4. taint 는 붙었는데 5분 동안 아무 일도 안 일어난다
```bash label="[터미널 B] ① 죽은 노드에 붙은 taint"
kubectl describe node kc-lab-2 | grep -A3 Taints
```
```bash label="[터미널 B] ② 같은 값을 한 줄로"
kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo
```
```bash label="[터미널 B] ③ 살아 있는 파드가 그것을 얼마나 참나"
kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations
```
①② 는 `node.kubernetes.io/unreachable=:NoSchedule` 과 `node.kubernetes.io/unreachable=:NoExecute` 를 낸다. `NoSchedule` 은 새 파드를 여기 보내지 말라는 뜻이고 `NoExecute` 는 이미 있는 파드도 쫓아내라는 뜻인데, 그런데도 아무 일이 안 일어나는 까닭이 ③ 에 있다.
```text
=== NoExecute taint 의 tolerationSeconds — 언제 축출되는가 ===
node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
node.kubernetes.io/unreachable NoExecute tolerationSeconds=300
```
`tolerationSeconds=300` 은 쿠버네티스가 모든 파드에 자동으로 붙인 값이다.
```text
기계 정지
│ 40초 node-monitor-grace-period → 노드 NotReady
│ +300초 tolerationSeconds (NoExecute) → 파드 축출 시작
총 약 5분 40초 동안 쿠버네티스는 아무것도 하지 않는다
```
### 5. 5분을 실제로 기다린다
```bash label="[터미널 B] ① 30초 간격으로 본다"
watch -n 30 'kubectl -n keycloak-lab get pods -o wide'
```
```text
+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 파드가 생기며 `Pending` 이다. 축출이 시작됐는데 `Terminating` 이 안 끝나고 새 파드는 뜨지 못하며, 두 문제는 원인이 다르다.
### 6. 새 파드가 갈 곳이 없다
```bash label="[터미널 B] ① Pending 인 파드 이름을 먼저 확인한다"
kubectl -n keycloak-lab get pods --field-selector=status.phase=Pending
```
```bash label="[터미널 B] ② 이름은 매번 다르므로 ① 에서 본 것을 옮겨 적는다"
kubectl -n keycloak-lab describe pod postgres-7b474b88c8-9cmsv | grep -A6 Events
```
```text
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` 뒤에 이유가 노드 수만큼 나열되므로 한 줄에 두 노드의 사연이 다 들어 있다.
```text
kc-lab-2 → had untolerated taint(s) (죽은 노드)
kc-lab-1 → didn't match PersistentVolume's node affinity
```
주입 전에 이미 알고 있던 것이 그대로 벌어졌다. 볼륨이 `kc-lab-2` 에 못박혀 있어 살아 있는 노드로 못 가고, 죽은 노드에는 taint 때문에 못 간다. 갈 곳이 없다. 이 실험대의 조건이고, 운영이라면 네트워크 스토리지나 DB 복제가 그 몫을 맡아야 한다. 노드가 영영 안 돌아오면 백업 복원(D-1)으로 간다.
### 7. StatefulSet 은 대체 파드를 안 만든다
```bash label="[터미널 B] ① 원하는 수와 현재 수"
kubectl -n keycloak-lab get statefulset keycloak
```
```bash label="[터미널 B] ② keycloak 파드만"
kubectl -n keycloak-lab get pods | grep keycloak
```
```text
keycloak 2 <none> 1
keycloak-0 1/1 Terminating 0 30m
keycloak-1 0/1 Running 0 143m
```
`DESIRED=2` 인데 `CURRENT=1` 이고 `keycloak-0` 이 30분째 `Terminating` 이다. StatefulSet 의 계약은 같은 이름의 파드가 클러스터에 하나뿐이어야 한다는 것이고, 컨트롤 플레인은 노드가 안 보이니 파드가 죽었는지 확신할 수 없어 옛 파드를 확실히 지우기 전엔 새 `keycloak-0` 을 못 만든다.
```text
파드 삭제 요청
└─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
└─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating
```
Deployment 였다면 즉시 새 파드를 만든다. 이름이 아무래도 되기 때문이고, postgres 가 실제로 그랬다. 강제로 진행시키는 명령이 있지만 이 절차에서는 치지 않는다.
```text
kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force
```
그것은 컨테이너가 실제로 죽었는지 모른 채 API 에서 지우는 것이라, 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 같은 이름의 파드 둘이 동시에 존재하게 된다. 이 실험대에서는 `virsh start` 가 훨씬 안전하고 빠르다.
### 8. 4b — kubectl 이 없으면 컨테이너 런타임에 직접 묻는다
**이 실험대는 한 줄로 쳤다.**
```bash label="[터미널 A] 실제로 친 형태"
ssh kc-lab-2 'sudo crictl ps --name keycloak'
```
**따라 하는 사람은** 붙고 나서 원격 셸에서 친다. 한 줄에 SSH 접속과 원격 셸의 인용을 겹쳐 놓지 않는다. 이 두 단계 형태는 이 실험대에서 치지 않았다.
```bash label="[터미널 A] ① 살아 있는 노드에 붙는다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] ② 컨테이너 런타임에 직접 묻는다"
sudo crictl ps --name keycloak
```
```text
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 서버가 없는데도 컨테이너는 돌고 있다.
```text
죽은 것: API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
산 것: keycloak-0 · postgres · containerd
문제: 들어갈 문(Traefik)이 없다
```
컨트롤 플레인 상실은 워크로드 상실과 다르다. 이미 떠 있는 것은 계속 돌고, 새로 뜨거나 옮기거나 고치는 것이 안 된다. 전체 목록은 `sudo crictl ps` 로 본다.
**③ 원격 셸에서 나온다.** 뒤의 단계는 다시 터미널 A 에서 치므로, 나오지 않으면 `kc-lab-2` 안에서 `kc-lab-2` 로 또 붙게 된다.
```bash label="[kc-lab-2] ③ 원격 셸에서 나온다"
exit
```
`crictl` 이 소켓을 못 찾으면 k3s 의 것을 직접 주는데, 그 줄은 미검증이다. **아래는 원격 셸이 아니라 터미널 A 에서 치는 형태다.**
```bash label="[터미널 A] 미검증 — 소켓을 직접 준다"
ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock ps'
```
### 9. 4b — 밖에서는 두 주소를 함께 본다
```bash label="[터미널 C] ① 20초 간격으로, 인증 서버"
curl -s -o /dev/null -w 'auth=%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
```bash label="[터미널 C] ② 같은 간격으로, Grafana"
curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/
```
```text
+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` 한 줄만 다르다. `503`(4a)은 nginx 와 Traefik 이 살아 있고 뒤에 보낼 파드가 없는 것, `502`(4b, 한 번)는 nginx 가 연결 실패를 제때 판정해 자기 힘으로 만든 것, `000`(4b, 대부분)은 nginx 가 죽은 주소를 기다리다 `--max-time 8` 에 먼저 걸렸다. `502` 가 한 번이라도 찍혔으므로 nginx 는 살아 있었고, 같은 고장인데 코드가 흔들리는 까닭은 타임아웃 경주다.
4b 구간의 지표는 지금 확인할 수 없다. Prometheus 도 Grafana 도 `kc-lab-1` 에 있었다.
```text
대상이 죽음 → up = 0 → "언제 죽었는지" 알 수 있다
관측자가 죽음 → 데이터 없음 → "그때 무슨 일이 있었는지" 모른다
```
## 복구와 원상복구 확인표
### 1. 워커 노드를 다시 켠다 (4a)
**목적** — `kc-lab-2` 를 되살려 파드와 볼륨과 정문을 제자리로 돌린다.
```bash label="[터미널 A] ① 재기동 시각"
date '+%H:%M:%S 재기동'
```
```bash label="[터미널 A] ② 전원을 넣는다"
virsh start kc-lab-2
```
```bash label="[터미널 B] ③ 30초 간격으로 노드와 파드"
kubectl get nodes
```
```bash label="[터미널 B] ④ 파드 상태"
kubectl -n keycloak-lab get pods
```
```bash label="[터미널 C] ⑤ 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**예상 결과**
```text
+30초 node=Ready | Running 파드 3 개 | 외부 HTTP 503
+60초 node=Ready | Running 파드 3 개 | 외부 HTTP 200
→ 서비스 복귀
```
60초 만에 사람 개입 없이 전부 제자리로 돌아왔다. `Terminating` 이던 파드도 `Pending` 이던 파드도 kubelet 이 돌아오자 정리됐다.
**왜 필요한가** — 이 60초는 MTTR(Mean Time To Recovery, 평균 복구 시간)이 아니다. `virsh start` 를 친 뒤의 시간이고, 실제 장애 구간은 `12:07:43`(차단)에서 `12:17:31`(서비스 복귀)까지 약 10분이며 그 대부분은 사람이 관찰하고 결정하는 데 썼다.
**문제가 생기면** — `postgres` 파드 이름이 바뀌어 있으면 정상이다. 축출 때 생겼다가 `Pending` 이던 파드가 노드가 살아나자 그대로 뜬 것이고, `keycloak-0` 은 StatefulSet 이라 이름이 그대로다.
```text
=== 복구 확인 ===
keycloak-0 1/1 Running 0 68s
keycloak-1 1/1 Running 0 144m
postgres-7b474b88c8-9cmsv 1/1 Running 0 4m20s
```
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| VM | `virsh list --all` | 둘 다 `running` |
| 노드 | `kubectl get nodes` | 둘 다 `Ready` |
| 파드 | `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` |
이 표를 통과하기 전에는 4b 로 넘어가지 않는다.
### 2. 컨트롤 플레인 노드를 다시 켠다 (4b)
**목적** — `kc-lab-1` 을 되살려 API 서버와 진입점과 관측 스택을 돌린다.
```bash label="[터미널 A] ① 재기동 시각"
date '+%H:%M:%S 재기동'
```
```bash label="[터미널 A] ② 전원을 넣는다"
virsh start kc-lab-1
```
```bash label="[터미널 B] ③ 파드 상태"
kubectl -n keycloak-lab get pods
```
**예상 결과**
```text
재기동: 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초)
```
`+30초` 의 `502` 는 nginx 가 먼저 살아나고 Traefik 이 아직 안 뜬 중간 상태이고, 4b 내내 보던 `000` 과 층이 다르다.
```text
keycloak-0 1/1 Running 0 7m57s
keycloak-1 1/1 Running 1 (<invalid> ago) 151m
postgres-7b474b88c8-9cmsv 1/1 Running 0 11m
```
**왜 필요한가** — 세 가지를 한 줄에서 읽는다. `keycloak-1` 의 `RESTARTS` 가 1 인 것은 `kc-lab-1` 위에 있었으니 당연하고, `AGE` 가 `151m` 인데 재시작은 방금인 것은 `AGE` 가 파드가 만들어진 시각이지 컨테이너가 시작한 시각이 아니기 때문이며, `(<invalid> ago)` 는 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다. 그 원인은 이 절차가 확정하지 못했고, 잠시 뒤 다시 치면 정상 값으로 바뀐다.
**문제가 생기면** — 복구 뒤에 Grafana 에서 `up{job="keycloak"}` 그래프를 `12:1512:30` 으로 열어 `12:1812:23` 구간이 0 이 아니라 빈칸인 것을 확인한다. 그 구간은 선이 0 으로 내려간 것이 아니라 아예 끊겨 있다. Grafana 로그인도 풀려 있는데 데이터가 `emptyDir` 이라 파드 재시작에 사라지기 때문이고, Prometheus 는 PVC 라 지표가 남았지만 관측자가 죽어 있던 구간의 데이터는 애초에 수집되지 않았다. Prometheus 를 `port-forward` 로 보고 있었다면 다시 연결한다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| VM | `virsh list --all` | 둘 다 `running` |
| 노드 | `kubectl get nodes` | 둘 다 `Ready` |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | 전부 `1/1 Running` |
| 진입점 | `kubectl -n kube-system get deploy traefik` | `1/1` |
| Service | `kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 |
| 클러스터 뷰 | `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` |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `virsh` 가 도메인을 못 찾는다 | `qemu:///session` 을 보고 있다 | `virsh uri` — `system` 이어야 한다 |
| VM 을 껐는데 노드가 `Ready` | 정상이다. `node-monitor-grace-period` 40초 | `virsh list --all` 로 전원을 먼저 본다 |
| 5분이 지나도 축출이 안 온다 | 40초 + `tolerationSeconds=300` = 5분 40초 | `describe pod \| grep -A4 Tolerations` |
| 새 파드가 계속 `Pending` | PVC 가 죽은 노드에 못박혀 있다 | `describe pod` 의 `FailedScheduling` |
| `keycloak-0` 이 30분째 `Terminating` | StatefulSet + kubelet 없음. 정상이다 | `get statefulset` 의 `CURRENT` |
| `--force` 로 지우고 싶다 | 노드가 살아 있으면 중복 실행이 된다 | 치지 말고 `virsh start` |
| `kubectl` 이 전혀 안 된다 (4b) | API 서버가 죽은 노드에 있었다. 정상 | `ssh kc-lab-2 'sudo crictl ps'` |
| `crictl` 이 소켓을 못 찾는다 | k3s 는 자기 containerd 소켓을 쓴다 | `--runtime-endpoint unix:///run/k3s/containerd/containerd.sock` |
| `503` 을 기대했는데 `000` | 층이 다르다. nginx 가 죽은 주소를 기다린다 | `--max-time` 을 늘려 보면 `502` 가 나온다 |
| `curl` 이 몇 분씩 안 끝난다 | `--max-time` 을 안 줬다 | 모든 외부 확인에 `--max-time 8` |
| 그래프의 그 구간이 0 으로 보인다 | 0 이 아니라 데이터 없음이다 | 점 사이가 이어져 있는지 본다 |
| Grafana 로그인이 풀렸다 | 데이터가 `emptyDir` | 재시작마다 그렇다. PVC 로 바꾸면 남는다 |
| Prometheus 가 갑자기 안 보인다 | `port-forward` 가 끊겼다 | 다시 연다 |
| `RESTARTS` 가 `1 (<invalid> ago)` | 재시작 직후에 나온다. 원인 미확정 | 잠시 뒤 다시 친다 |
| 4b 결과가 4a 와 섞인다 | 4a 복구를 확인하지 않고 넘어갔다 | 4a 확인표를 통과한 뒤 시작 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 차단 `12:07:43` · 재기동 `12:16:31` · 2차 차단 `12:18:08` · 2차 재기동 `12:23:39`, `+30초` 까지 `Ready` 이고 `+45초` 에 `NotReady`, 그 동안 외부가 `000` 에서 `503` 으로, 죽은 노드 파드의 `ready=true`, `up` 일곱 줄, taint 두 종류, `tolerationSeconds=300`, `+270초` 의 축출과 `Pending`, `0/2 nodes are available` 줄, `DESIRED=2 / CURRENT=1` 과 30분째 `Terminating`, `crictl` 출력 한 줄, `grafana=502` 한 번, 양쪽 복구 60초, postgres 이름이 `-2gf27` 에서 `-9cmsv` 로 바뀐 것.
- 인용한 값이고 잰 값이 아닌 것 — 본문의 40초와 5분은 쿠버네티스 기본값을 인용했다. 관측된 전이 시점(`+45초`, `+270초`)이 그 값과 모순되지 않는다는 것까지가 이 절차가 말할 수 있는 범위이고, 값 자체를 측정하지는 않았다.
- (unknown) `ping -c 2 -W 2 192.168.122.12`(원 실행에 없다), 노드 상태와 외부 코드를 한 줄로 묶는 `while` 루프, `tr ',' '\n' | grep -E` 로 자른 `up` 출력, `sudo tail -f /var/log/nginx/error.log`, `crictl` 에 소켓을 직접 주는 줄, `ssh kc-lab-2` 로 들어가서 `crictl ps` 를 따로 치는 두 단계 형태.
- 이 절차가 답을 못 남긴 곳 — 진입점이 처음 40초간 `000` 이었던 까닭을 nginx 로그로 확인하려던 절이 증거 파일에서 제목만 있고 아래가 비어 있다. 명령이 아무것도 찍지 못했다.
- 원인을 확정하지 않은 것 — `RESTARTS` 의 `(<invalid> ago)`.
- 재지 않은 것 — 노드가 영영 안 돌아오는 경우. `local-path` PVC 가 그 노드와 함께 없어진 상태에서의 복구는 D-1(백업·복원)의 주제다.
- 이 절차가 남기는 구성 숙제 셋 — Traefik `replicas=1` 이라 진입점이 단일 장애점인 것(`replicas=2` 로 늘리거나 DaemonSet 으로), Grafana 가 `emptyDir` 이라 재시작마다 세션이 사라지는 것(PVC 를 붙인다), 관측 스택이 실험 대상 노드에 함께 있는 것(노드가 둘뿐이라 완전히는 못 피한다).
<!-- body:end -->
@@ -0,0 +1,885 @@
---
id: b90d719f-39fb-4bab-a263-0e32eedb2b36
kind: SETUP
slug: reproduce-a5-asymmetric-partition
title: 한 방향만 끊어 보고 raw PREROUTING 까지 내려간다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b90d719f-39fb-4bab-a263-0e32eedb2b36/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-5
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 한 방향만 끊어 보고 raw PREROUTING 까지 내려간다
`iptables` 로 Keycloak 두 노드 사이의 JGroups 채널을 끊는 절차다. 주입 넷 중 앞의 둘은 일부러 실패시킨다 — 실패한 주입은 화면에 「아무 일도 없었다」로 보이므로 절차의 절반이 패킷 카운터를 읽는 일이다. 전 구간 약 30분.
## 관계
- **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것**
기계를 끄지 않고 네트워크만 끊었을 때 무엇이 다른지를 그 기록과 견주면 갈린다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
여기서 일부러 밟는 실패 둘이 그 아홉 건에 들어 있다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
카운터로 주입을 먼저 판정하고 그다음에 클러스터를 보는 이 순서가 그 기준을 따른다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
양방향 차단에서 `keycloak-1``0/1` 로 내려가고 정문이 계속 `200` 을 내는 상태를 이 절차가 만든다.
- **7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다**
앞 편이고, 거기서 NetworkPolicy 가 기존 연결을 못 끊은 것이 이 편의 출발 조건이다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
`kubectl``kc-lab-1` 에서 친다. `iptables` 는 노드 자체를 건드리는 명령이라 게스트 셸이 필요하고, 두 노드에 각각 넣어야 하며 어느 노드에 넣느냐가 결과를 가른다. 이쪽 노드의 규칙은 `[kc-lab-1]` 에서 그대로 치고, 반대 노드의 규칙은 `ssh kc-lab-2` 로 붙어서 친다. 코드블록마다 어느 셸인지 붙여 두었다.
가이드는 터미널 둘을 권한다 — 하나는 상주 탐침 파드용, 하나는 관찰용이다. 다만 이 절차에는 탐침 파드의 셸 안에서 치는 명령이 하나도 없다. `a5-probe``kubectl exec` 으로만 쓰므로 명령은 전부 `[kc-lab-1]` 에서 치고, `$K0``$K1` 도 그 셸의 변수다. 다른 터미널에서 관찰 §7 의 헬스체크를 치면 두 변수가 빈 문자열이라 양쪽 다 안 닿고, 그 화면을 「둘 다 DOWN」으로 읽게 된다. 실제로는 한쪽만 `DOWN` 이다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 막는 포트 | `7800`(트랜스포트) 과 `57800`(FD_SOCK2 = `bind_port + 50000`) |
| 주입 지점 | `raw PREROUTING`. `filter FORWARD` 는 kube-router 와 경쟁한다 |
| 탐침 파드 | `a5-probe``curlimages/curl:8.11.1`, `sleep 1800`, `--restart=Never` |
| 로그 시각 | 컨테이너는 UTC. KST 에서 9시간을 뺀다 |
| 걸리는 시간 | 전 구간 약 30분 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
## 이 실험이 가르는 것
A-1 이 답하지 못하고 넘긴 물음에서 출발한다. 가이드는 그 물음을 그대로 인용한다.
> `keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고, `keycloak-0` 은 합류 자체를 못 해 `DOWN` 이 됐다. 양쪽이 동시에 `DOWN` 이 되는 경로가 있다면 전면 장애다.
A-1 은 도구도 하나 남겼다. NetworkPolicy 는 기존 연결을 못 끊는다 — conntrack 의 `ESTABLISHED` 가 먼저 통과시킨다. 그래서 이번에는 `iptables` 로 직접 간다.
그런데 `iptables` 에도 벽이 셋 있다. 이 절차는 그 셋을 일부러 다시 밟는다.
```text
실패 ① filter FORWARD 최상단에 넣었는데 → CNI 가 밀어낸다
실패 ② raw 로 옮겼는데도 0 패킷 → 연결 방향을 잘못 짚었다
성공 수신측 노드의 raw PREROUTING → 19 패킷
그런데 그래도 안 갈라진다 → 반대 방향으로 재연결한다
```
끝나면 이것들을 자기 화면에서 본다 — 규칙을 넣었는데 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``06-observability` 가 끝나 있다.
- A-1 을 먼저 해 두면 훨씬 이해가 빠르다. 이 절차는 A-1 이 실패한 곳에서 시작한다.
- `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. 앞 절의 표가 어느 명령을 어느 셸에서 치는지 적는다.
:::warning
이 절차는 Keycloak 클러스터를 실제로 분단시킨다. 실험대에서만 한다.
:::
### 1. 중단하는 방법을 먼저 확인한다
**목적** — 어느 단계에서든 두 노드의 규칙을 한꺼번에 걷어낼 수 있게 해 둔다.
**이 실험대는 두 줄로 쳤다.** 둘째 줄에 SSH(Secure Shell, 원격 셸) 접속과 원격 셸의 인용과 세미콜론으로 이은 명령 둘이 한꺼번에 들어 있다.
```bash label="[kc-lab-1] 실제로 친 형태"
sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD'
```
**따라 하는 사람은** 반대 노드 쪽을 나눈다. 먼저 붙고, 붙은 다음에 두 줄을 따로 친다. 행동 하나가 명령 하나가 된다. 이 나눈 형태는 이 실험대에서 치지 않았다.
이쪽 노드의 규칙을 먼저 걷어낸다.
```bash label="[kc-lab-1] ① 지우기 전에 무엇이 있었는지 본다"
sudo iptables -S FORWARD
```
```bash label="[kc-lab-1] ② 두 체인을 비운다"
sudo iptables -t raw -F PREROUTING
sudo iptables -F FORWARD
```
그다음 반대 노드에 붙는다.
```bash label="[kc-lab-1] ③ 게스트 셸로 들어간다"
ssh kc-lab-2
```
같은 두 줄을 원격 셸에서 친다.
```bash label="[kc-lab-2] ④ 두 체인을 비우고 나온다"
sudo iptables -t raw -F PREROUTING
sudo iptables -F FORWARD
exit
```
**예상 결과** — `-F` 는 아무것도 찍지 않는다. ① 에 무엇이 있었는지가 유일한 기록이므로 건너뛰지 않는다.
**왜 필요한가** — `-F FORWARD` 는 그 체인 전체를 비운다. 이 실험대의 `FORWARD` 정책은 `ACCEPT` 이고 실제 규칙은 kube-router 와 kube-proxy 가 자기 체인에 두므로 잠시 뒤 스스로 복구된다. 그래도 무엇을 지웠는지 모르면 나중에 클러스터가 이상할 때 이 명령 탓인지 가릴 수 없다.
**문제가 생기면** — 해제했는데 2~3분째 클러스터가 안 붙으면 반대 노드 규칙이 남아 있는 것이므로 두 노드 모두에서 `-t raw -S PREROUTING` 을 본다.
## 주입 전에 같은 명령으로 먼저 본다
주입이 걸리기 전과 후가 화면상 똑같이 보이는 절차다. 먼저 본 것이 없으면 실패를 성공으로 읽는다. 순서는 이렇다.
```text
파드 IP·노드 → 디스커버리(DB) → 클러스터 뷰(로그) → 지표 → 연결 방향 → 밖
```
### 1. 파드 IP 와 노드 배치를 지금 다시 뽑는다
**무엇을 확인하는가** — 어느 파드가 어느 노드에 있고 IP(Internet Protocol 주소)가 무엇인지.
```bash label="[kc-lab-1] ① 배치와 IP"
kubectl -n keycloak-lab get pods -o wide
```
```bash label="[kc-lab-1] ② 뒤에서 계속 쓸 두 값을 셸 변수에 담는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "K0=$K0 K1=$K1"
```
**출력에서 답이 되는 것** — `NODE` 열과 파드 번호의 짝이다. 실측은 이렇다.
```text
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` 로). 파드가 재시작되면 바뀌므로 여기 적힌 값을 쓰지 말고 ② 로 지금 뽑는다. `keycloak-1` 의 `RESTARTS` 가 1 인 것은 A-4 에서 노드를 껐다 켠 흔적이다.
### 2. 디스커버리와 클러스터 뷰
**무엇을 확인하는가** — 지금 코디네이터가 하나인지, 그리고 뷰 ID 가 몇인지.
```bash label="[kc-lab-1] ① 디스커버리는 DB 가 말한다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
```bash label="[kc-lab-1] ② 뷰는 로그가 말한다"
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
```
**출력에서 답이 되는 것** — ① 의 `coord` 열과 ② 의 뷰 ID 다. 모양은 이렇고 숫자는 환경마다 다르다.
```text
name | ip | coord
------------------+-----------------+-------
keycloak-0-24309 | 10.42.1.77:7800 | f
keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)
```
```text
ISPN000094: [keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)]
```
**이 결과가 뜻하는 것** — `coord` 열에 `t` 가 정확히 하나여야 한다. 둘이면 이미 갈라져 있고, 그 상태에서 주입해 봐야 아무것도 판정하지 못한다. 뷰 ID(`|13`)를 적어 둔다. 이 절차의 판정 기준이 그 숫자의 변화다.
### 3. 지표는 밖에서 Prometheus 에 묻는다
**무엇을 확인하는가** — 두 노드가 보는 클러스터 크기.
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`).
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 처음 한 번은 그대로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
```bash label="[kc-lab-1] ② 미검증 — 라벨과 값만 남겨 자르는 형태"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**출력에서 답이 되는 것** — `value` 배열의 두 번째 값이다. 모양은 이렇고 값은 환경마다 다르다.
```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"]}]}}
```
**이 결과가 뜻하는 것** — 두 줄이고 값이 둘 다 `2` 다. 원 실행에서는 이 값이 안 남았다. 값을 뽑으려고 붙인 파이썬 한 줄이 죽으면서 원본까지 같이 사라졌고, 화면에 남은 것은 스택트레이스뿐이다.
```text
Traceback (most recent call last):
File "<string>", line 3, in <module>
for r in json.load(sys.stdin)["data"]["result"]: print(f" cluster_size {r["metric"].get("pod"):12} = {r["value"][1]}")
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
```
`wget` 원문을 먼저 보고 나중에 자르면 같은 일이 안 생긴다.
### 4. 연결 방향 — 이 절차에서 가장 중요한 사전 관측이다
**무엇을 확인하는가** — 두 파드 중 어느 쪽이 클라이언트이고 어느 쪽이 서버인지.
**이 실험대는 두 줄로 쳤다.**
```bash label="[kc-lab-1] 실제로 친 형태"
sudo conntrack -L 2>/dev/null | grep 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
```
**따라 하는 사람은** 둘째 줄을 나눈다. 붙고 나서 원격 셸에서 친다. 이 나눈 형태는 이 실험대에서 치지 않았다.
```bash label="[kc-lab-1] ① 반대 노드에 붙는다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] ② 같은 명령을 원격 셸에서"
sudo conntrack -L 2>/dev/null | grep 7800
exit
```
**출력에서 답이 되는 것** — `dport=7800` 인 쪽이 서버이고 `src` 가 클라이언트다.
```text
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
──────────── ────────────────────
keycloak-0 가 클라이언트 keycloak-1 이 서버
```
**이 결과가 뜻하는 것** — 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」 요약을 지우는 것이므로, 처음에는 빼고 쳐서 그 줄도 한 번 본다.
### 5. 밖에서 보이는 상태
**무엇을 확인하는가** — 정문이 지금 무엇을 답하는지.
```bash label="[kc-lab-1] ① 응답을 통째로 읽는 형태"
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
```
```bash label="[kc-lab-1] ② 여러 번 비교할 것이므로 코드만 뽑는 형태"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**출력에서 답이 되는 것** — `200` 이어야 한다.
**이 결과가 뜻하는 것** — 주입 뒤에도 이 값이 `200` 으로 남는 것이 이 절차의 결론 하나다. 지금 값을 안 잡아 두면 나중에 그것을 말할 수 없다.
## 주입
주입은 네 번이고 앞의 둘은 일부러 실패한다. 건너뛰지 않는다 — 이 실패의 모양을 봐 둬야 다음에 자기 규칙을 의심할 수 있다.
**넷을 연달아 걸지 않는다.** 하나를 걸고 같은 번호의 주입 검증을 친 뒤 그 규칙을 걷어내고 다음으로 간다. 넷이 같은 두 포트를 막으므로 걷어내지 않고 쌓으면 어느 규칙이 잡았는지 가릴 수 없다. 치는 순서는 이렇다.
```text
주입 ① ─▶ 검증 §1 ─▶ 관찰 §1 ─▶ 주입 ① 의 ④⑤ 로 철거 ─▶ 주입 ② ─▶ 검증 §2 ─▶ 주입 ② 의 ④⑤ 로 철거
┌────────────────────────────────────────────────────────────────────────────────┘
주입 ③ ─▶ 검증 §3 ─▶ 관찰 §2~§4 ─▶ 주입 ④ (③ 의 규칙은 그대로 둔다) ─▶ 검증 §4 ─▶ 관찰 §5~§7 ─▶ 복구
```
주입 ③ 의 규칙만 예외다. 네 번째 주입이 「`kc-lab-1` 의 규칙은 그대로 두고」 반대 방향을 더하는 것이라, 셋째만 걷어내지 않고 이어 간다.
### 1. 시도 ① — filter 테이블 최상단
**목적** — `FORWARD` 최상단에 넣으면 conntrack 승인보다 먼저 평가되리라는 가설을 실제로 확인한다.
```bash label="[kc-lab-1] ① 7800 을 FORWARD 1번에 넣는다"
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 7800 -j DROP"
```
```bash label="[kc-lab-1] ② 57800 도 같이 막는다"
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 57800 -j DROP"
```
```bash label="[kc-lab-1] ③ 주입 시각"
date '+%H:%M:%S 주입'
```
**예상 결과** — `iptables` 는 아무것도 찍지 않는다. `주입: 12:28:23` 만 남는다.
이 줄들은 「전제와 되돌리기」의 중단 절차처럼 나눠 치면 안 된다. 거기는 `ssh kc-lab-2` 로 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 가 들어간다. `$K0` 는 `[kc-lab-1]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[kc-lab-1]` 에서 펴서 보내므로 한 줄 형태 그대로 친다.
**왜 필요한가** — 57800 을 같이 막는 것은 FD_SOCK2(장애 감지 채널)가 `bind_port + 50000` 을 쓰기 때문이다. 7800 만 막으면 장애 감지는 계속 통해서 분단이 어정쩡해진다.
**결과를 본 다음 반드시 걷어낸다.** 주입 검증 §1 을 치고 여기로 와서 ④ 와 ⑤ 를 친다. 시도 ② 는 같은 두 포트를 같은 노드에서 다시 막으므로, `FORWARD` 에 남은 `DROP` 둘을 그대로 두면 어느 테이블이 패킷을 잡았는지 가릴 수 없다. 치우는 명령은 넣을 때와 인자가 같아야 한다.
```bash label="[kc-lab-1] ④ 넣을 때와 같은 인자로 지운다"
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"
```
```bash label="[kc-lab-1] ⑤ 지워졌는지 줄 번호로 확인한다"
ssh kc-lab-2 'sudo iptables -L FORWARD -n --line-numbers | head -5'
```
**문제가 생기면** — ⑤ 에 `DROP` 이 남아 있으면 줄 번호로 지운다 — `sudo iptables -D FORWARD 3`.
### 2. 시도 ② — raw 테이블로 옮긴다
**목적** — conntrack 조회보다 먼저 평가되는 체인에 같은 규칙을 넣는다.
netfilter 의 처리 순서가 그 근거다.
```text
패킷 도착
├─▶ 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 가 밀어낸다) |
| raw PREROUTING | 끊는다 | 없다 — CNI 가 안 쓰는 테이블 |
```bash label="[kc-lab-1] ① 테이블만 바꾸고 노드와 목적지는 그대로 둔다"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
```
```bash label="[kc-lab-1] ② 57800 도 같이"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
```
```bash label="[kc-lab-1] ③ 주입 시각"
date '+%H:%M:%S 주입'
```
**예상 결과** — 규칙이 목록에 보이고, 카운터는 0 으로 남는다. 왜 0 인지는 다음 절이 답한다.
**왜 필요한가** — 목적지와 노드는 그대로 두고 테이블만 바꿔야 무엇 때문에 결과가 달라졌는지 하나씩 가릴 수 있다.
**이 규칙도 반드시 걷어낸다.** 주입 검증 §2 를 치고 여기로 와서 ④ 와 ⑤ 를 친다. **네 번째 주입이 `kc-lab-2` 의 `raw PREROUTING` 에 글자까지 같은 두 줄을 다시 넣는다.** 걷어내지 않으면 같은 규칙이 두 벌 쌓여 카운터가 네 줄로 갈리고, 검증 §4 의 예상 결과는 그 모양을 적어 두지 않았다. 지우기 전에 `-L` 로 무엇이 있는지 본다. `-F` 는 체인 전체를 비운다.
```bash label="[kc-lab-1] ④ 무엇이 있는지 먼저 본다"
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n --line-numbers'
```
```bash label="[kc-lab-1] ⑤ 체인을 비운다"
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
**문제가 생기면** — ④ 에 `kube-router` 규칙이 섞여 있으면 `-F` 대신 줄 번호로 내가 넣은 둘만 지운다.
### 3. 성공한 주입 — 수신측 노드의 raw PREROUTING
**목적** — 7800 으로 실제로 들어가는 패킷을 잡는다. 목적지 파드가 있는 노드에서 잡아야 한다.
```bash label="[kc-lab-1] ① keycloak-1 의 IP 를 목적지로, 이 노드에 넣는다"
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP
```
```bash label="[kc-lab-1] ② 57800 도 같이"
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
```
```bash label="[kc-lab-1] ③ 주입 시각"
date '+%H:%M:%S 주입'
```
**예상 결과**
```text
=== keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 ===
주입: 12:33:58
```
**왜 필요한가** — 시도 ② 는 `10.42.1.77`(keycloak-0)을 목적지로 잡았는데 그것은 이 연결의 출발지다. 7800 으로 들어가는 패킷은 `10.42.0.42`(keycloak-1) 쪽으로 간다.
```text
내가 막은 것: → 10.42.1.77:7800 (그런 패킷이 없다)
실제 흐름: → 10.42.0.42:7800 (여기를 막아야 한다)
```
**문제가 생기면** — 앞의 `conntrack -L` 출력을 다시 본다. 방향이 바뀌었으면 목적지도 바뀐다.
### 4. 네 번째 주입 — 양방향
**목적** — `kc-lab-1` 의 규칙은 그대로 두고 `kc-lab-2` 에 반대 방향을 더해 실제 분단을 만든다.
```bash label="[kc-lab-1] ① 반대 방향을 반대 노드에 더한다"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
```
```bash label="[kc-lab-1] ② 57800 도 같이"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
```
```bash label="[kc-lab-1] ③ 주입 시각"
date '+%H:%M:%S 주입'
```
**예상 결과**
```text
=== 양방향 차단 — 두 노드 모두에 raw DROP ===
주입: 12:40:25
```
**왜 필요한가** — 한 방향만 막으면 JGroups 가 열린 방향으로 다시 붙는다. 양쪽을 막아야 `coord = t` 가 둘이 된다.
**문제가 생기면** — 양쪽 카운터를 다 본다. 한쪽만 걸리면 그것은 여전히 단방향이다.
## 주입 검증
카운터가 유일한 판정 기준이다. 규칙이 목록에 보이는 것은 검증이 아니다.
### 1. 시도 ① — 넣은 직후에는 맞게 보인다
```bash label="[kc-lab-1] ① 규칙과 카운터를 함께 본다"
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
```
넣은 직후의 실측은 이렇다.
```text
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
```
여기서 만족하고 넘어가면 속는다. 1~2분 뒤 같은 명령을 다시 친다.
```text
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(Container Network Interface, 컨테이너 네트워크 플러그인 규격) 가 관리하는 체인과 경쟁하므로, 넣는 것으로 끝나지 않고 패킷 카운터로 확인해야 한다.
이 확인을 담았어야 할 `02-injection-verify.txt` 는 원 실험 시점에 0바이트로 저장됐다. 리다이렉션이 stdout 만 받았는데 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 사후에 다시 수집한 것이고, 원 시점의 `DROP` 규칙은 이미 없어서 재현되지 않는다. 남아 있는 사실은 kube-router 체인이 `FORWARD` 1번을 차지하고 있다는 것 하나이고, 따라 하는 사람은 실제 카운터를 볼 수 있다.
### 2. 시도 ② — CNI 와 경쟁하지도 않는데 0 이다
```bash label="[kc-lab-1] ① raw 체인의 카운터"
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
```text
=== [검증] 이번엔 패킷이 걸렸는가 ===
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
```
앞에서 본 `conntrack` 이 답이다. `-d 10.42.1.77 --dport 7800` 은 존재하지 않는 패킷을 노린 규칙이었다. 규칙을 넣은 노드도 틀렸다.
### 3. 성공한 주입 — 처음으로 숫자가 올라간다
```bash label="[kc-lab-1] ① 이 노드의 raw 체인"
sudo iptables -t raw -L PREROUTING -n -v
```
```text
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 는 이미 붙어 있는 연결을 쓰고 있어서 새 연결을 시도하지 않았다. 조금 지나면 이쪽에도 숫자가 올라간다.
```text
=== 차단 규칙 누적 카운터 ===
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` 를 좁힌다 |
### 4. 양방향 주입 — 양쪽 카운터를 다 본다
```bash label="[kc-lab-1] ① 이 노드"
sudo iptables -t raw -L PREROUTING -n -v
```
```bash label="[kc-lab-1] ② 반대 노드"
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
한쪽만 걸리면 그것은 여전히 단방향이고, 그 상태에서 클러스터를 봐도 앞 단계와 같은 답만 나온다.
## 관찰
### 1. 시도 ① 뒤 — 아무 일도 없다
**시도 ① 의 규칙이 `FORWARD` 에 걸려 있는 동안 친다.** 주입 §1 의 ④⑤ 로 걷어낸 뒤에 치면 규칙이 없는 상태를 재게 되는데, 화면은 규칙이 있든 없든 `외부 200` 이라 틀렸다는 신호가 안 나온다.
```bash label="[kc-lab-1] ① 25초 간격으로 몇 번 친다"
kubectl -n keycloak-lab get pods | grep keycloak
```
```bash label="[kc-lab-1] ② 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
```text
+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. 성공한 단방향 주입 뒤 — 흔들렸다가 스스로 낫는다
같은 두 줄이 다른 답을 낸다.
```text
+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초` 에 돌아온다. 주입이 닿기는 했고(시도 ① 의 아무 일 없음과 다르다) 스스로 나았다.
### 3. 로그 시각은 UTC 다
```bash label="[kc-lab-1] ① 최근 20분의 뷰 변화"
kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094
kubectl -n keycloak-lab logs keycloak-1 --since=20m | grep ISPN000094
```
시각을 비교하려다 대부분 한 번은 틀린다.
```text
당신 셸의 date 12:33:58 KST
컨테이너 로그의 시각 03:33:58 ← 같은 순간이다. UTC 다
```
Keycloak 컨테이너는 UTC(Coordinated Universal Time, 협정 세계시)로 찍는다. KST(Korea Standard Time, 한국 표준시)는 UTC+9 이므로 9시간을 빼서 맞춰 본다. 이걸 모르면 주입 전 로그와 주입 후 로그를 정반대로 가른다.
```text
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초였다.
```text
03:33:49 MergeView 로 뷰 13 이 만들어짐 ← 그 직전에는 |12] (1), 즉 분단 상태였다
03:33:58 내 주입 ← 9초 뒤
```
앞선 실패한 주입 시도들이 만든 흔들림이 막 봉합된 직후였다. 로그 한 줄만 보고 「변화 없음」이라고 말하지 않고, 그 줄이 언제 생겼는지를 함께 본다. 결론 자체는 유지되지만 먼저 본 상태가 9초짜리였다는 사실을 같이 적어야 정직하다.
### 4. 왜 안 갈라졌나 — 연결이 뒤집혔다
앞에서 친 것과 똑같은 명령을 다시 친다. 그것이 대조하는 방법이다.
```bash label="[kc-lab-1] ① 차단 전에 친 것과 같은 명령"
sudo conntrack -L 2>/dev/null | grep 7800
```
```text
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` 를 앞의 관측과 나란히 놓는다.
```text
차단 전: 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 label="[kc-lab-1] ① 의심 카운터"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_fd_sock2_get_num_suspected_members'
```
```bash label="[kc-lab-1] ② 병합 횟수"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
```
```text
keycloak-0 merge_events=1.0 suspected=0.0
keycloak-1 merge_events=1.0 suspected=0.0
```
`suspected = 0` 이므로 아무도 상대를 의심하지 않았다. 끊긴 적이 없는 것과 같다. `merge_events = 1` 은 9초 전 병합의 값이다. 한 방향만 막는 것으로는 JGroups 를 가를 수 없다 — 두 노드는 서로에게 연결을 걸 수 있으므로 한쪽 길이 막히면 다른 길로 간다. 운영에서는 단방향 방화벽 오설정이 자가 치유된다는 뜻이고, 분단을 재현하려는 실험자에게는 함정이다.
### 5. 양방향으로 막으면 갈라진다
```bash label="[kc-lab-1] ① 25초 간격으로 파드"
kubectl -n keycloak-lab get pods | grep keycloak
```
```bash label="[kc-lab-1] ② Service 에서 빠졌는지"
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
```bash label="[kc-lab-1] ③ 같은 간격으로 밖에서"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
```text
+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` 로 내려가서 안 돌아오고(단방향 때와 다르다), ready 주소가 둘에서 하나로 줄었으며, 외부는 계속 `200` 이다. `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 `endpointslice` 를 본다.
뷰도 갈린다.
```text
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. split brain 은 DB 한 줄로 확인한다
```bash label="[kc-lab-1] ① 앞에서 친 것과 같은 쿼리"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
```text
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` 가 둘이다. 앞에서 하나였던 것과 대조한다. 분단을 확인하는 가장 짧은 명령이 이 한 줄이고, 로그를 두 번 긁는 것보다 빠르며 지표보다 정확하다.
### 7. 그런데 한쪽만 `DOWN` 이다
Keycloak 컨테이너에 `curl` 이 없으므로 상주 파드를 띄운다.
```bash label="[kc-lab-1] ① 상주 탐침을 띄운다"
kubectl -n keycloak-lab run a5-probe --image=curlimages/curl:8.11.1 \
--restart=Never --command -- sleep 1800
```
```bash label="[kc-lab-1] ② 뜰 때까지 기다린다"
kubectl -n keycloak-lab wait --for=condition=Ready pod/a5-probe --timeout=120s
```
```bash label="[kc-lab-1] ③ 두 노드의 헬스체크를 각각"
kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K0:9000/health/ready"
kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K1:9000/health/ready"
```
일회용 파드를 안 쓰는 까닭은 원 실행이 `--rm -it` 로 했다가 붙지 못했기 때문이다.
```text
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
```
파드가 뜬 뒤 명령이 끝나 버리기 전에 붙어야 하는 경주가 된다. 관찰을 여러 번 반복할 것이라면 상주 파드가 항상 낫다.
**상주 파드라서 `--rm` 이 없다.** 그래서 이 절차를 두 번째 칠 때는 앞선 실행의 `a5-probe` 가 같은 이름으로 이미 있어 ① 이 `AlreadyExists` 로 거절된다. 아래 원상복구 확인표의 삭제 명령을 먼저 치고 ① 로 돌아온다.
```text
--- 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 가 모두 의존하는 하나인 데 비해 클러스터 멤버십은 서로 상대적이라는 것이다.
## 복구와 원상복구 확인표
### 1. 두 노드의 규칙을 걷어낸다
**목적** — 양쪽 `raw PREROUTING` 을 비워 JGroups 가 다시 붙게 한다.
지우기 전에 무엇이 있는지 본다.
```bash label="[kc-lab-1] ① 이 노드"
sudo iptables -t raw -L PREROUTING -n -v --line-numbers
```
```bash label="[kc-lab-1] ② 반대 노드"
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers'
```
그다음 해제 시각을 찍고 양쪽을 비운다.
```bash label="[kc-lab-1] ③ 해제 시각"
date '+%H:%M:%S 해제'
```
```bash label="[kc-lab-1] ④ 이 노드를 비운다"
sudo iptables -t raw -F PREROUTING
```
```bash label="[kc-lab-1] ⑤ 반대 노드를 비운다"
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
**예상 결과** — `해제: 12:44:37` 이 남고, 25초 간격으로 파드를 보면 이렇다.
```text
+25초 keycloak-0:1/1 keycloak-1:0/1
+50초 keycloak-0:1/1 keycloak-1:1/1
→ 복구 완료
```
50초, 사람 개입 없음. conntrack 은 건드리지 않아도 된다 — 차단이 풀리면 새 연결이 스스로 성립한다.
**왜 필요한가** — 두 노드 중 한쪽만 비우면 여전히 단방향 차단이 걸려 있는 것이고, 클러스터는 열린 방향으로 붙어 겉보기에 복구된 것처럼 보인다.
여기서 비우는 것은 `raw` 뿐이다. 시도 ① 은 `kc-lab-2` 의 `filter FORWARD` 에 `DROP` 둘을 넣었는데 여기의 ④⑤ 는 그 체인을 안 본다. 주입 §1 의 ④ 를 그때 쳤으면 이미 없고, 건너뛰었으면 지금 남아 있다. 아래 원상복구 확인표의 `filter 규칙` 줄도 `kc-lab-1` 만 보므로 잡히지 않는다. 남아 있다면 「전제와 되돌리기」 §1 의 중단 절차를 친다 — 그쪽이 두 노드의 두 체인을 다 비운다.
**문제가 생기면** — 2~3분째 안 붙으면 반대 노드 규칙이 남아 있다. 두 노드 모두에서 `-t raw -S PREROUTING` 을 본다.
### 2. 누가 붙였는지는 MergeView 가 말한다
```bash label="[kc-lab-1] ① 양쪽 로그의 마지막 병합"
kubectl -n keycloak-lab logs keycloak-0 | grep MergeView | tail -1
kubectl -n keycloak-lab logs keycloak-1 | grep MergeView | tail -1
```
```text
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)`, 양쪽이 같은 줄이다.
```text
[keycloak-0-24309|13] (2) ← 정상
[keycloak-0-24309|14] (1) ← 분단. 양쪽이 각자 14 를 발행
MergeView::[...|15] (2) ← 병합. 뷰 ID 는 계속 증가한다
```
뷰 ID 는 단조 증가하므로 언제 몇 번 갈라졌는지를 로그만으로 셀 수 있다.
```bash label="[kc-lab-1] ② 캐시별 재분배 로그"
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6
```
```text
[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 ...
```
증거 파일의 원문은 한 줄이 길어 잘려 있다. 그 모양도 한 번 본다.
```text
2026-09-04 03:32:59,874 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo
```
`ISPN100007` 은 병합 또는 코디네이터 변경 후의 캐시별 토폴로지 재계산이다. 캐시가 여럿이므로 로그도 캐시 수만큼 나오고, 한 줄만 보고 한 번 재분배됐다고 세면 틀린다.
### 3. 원상복구 확인표
`filter 규칙` 줄은 `kc-lab-1` 만 본다. 시도 ① 이 `kc-lab-2` 의 `FORWARD` 에 넣은 `DROP` 둘은 이 표로 안 잡히므로, 그쪽이 의심되면 「전제와 되돌리기」 §1 의 중단 절차를 친다. 반대 노드의 `FORWARD` 를 조회하는 명령은 원 가이드에 없다(unknown).
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 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'` | 같음 |
| 파드 | `kubectl -n keycloak-lab get pods` | `keycloak` 둘 다 `1/1 Running` |
| 디스커버리 | `psql -c "select name, ip, coord from jgroups_ping order by name"` | `coord = t` 가 하나 |
| 뷰 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)`, 양쪽 동일 |
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 |
| 탐침 파드 | `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` |
```bash label="[kc-lab-1] 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found
```
## 막히면
아래는 이 실험대가 실제로 겪은 증상이다. 마지막 줄만 A-2·A-3 에서 겪은 것을 옮겼다 — 탐침 파드를 같은 방식으로 띄우므로 여기서도 그대로 걸린다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 규칙을 넣었는데 아무 일도 없다 | 카운터가 0 이면 아무것도 측정 안 된 것 | `iptables -L -n -v` 의 `pkts` |
| 내 규칙이 1번이 아니다 | kube-router 가 자기 체인을 재삽입한다 | `--line-numbers` 로 순서 |
| `raw` 인데도 0 패킷 | 연결 방향을 잘못 짚었다 | `conntrack -L \| grep 7800` |
| conntrack 에 아무것도 안 보인다 | 반대 노드에서 봤다 | 두 노드 모두에서 본다 |
| 단방향인데 안 갈라진다 | 정상이다. 열린 방향으로 재연결한다 | `conntrack` 의 `src`/`dst` 뒤집힘 |
| 로그에 변화가 없어 보인다 | 컨테이너 로그는 UTC. KST 와 9시간 차 | `logs` 의 시각에서 9를 뺀다 |
| 「주입 전부터 그대로」인데 미심쩍다 | 그 「전」이 9초일 수 있다 | 앞 뷰가 언제 생겼는지 본다 |
| `-D` 로 규칙이 안 지워진다 | 넣을 때와 인자가 다르다 | 줄 번호로 지운다: `-D FORWARD 3` |
| 임시 파드에 attach 실패 | `--rm -it` 는 경주가 된다 | 상주 파드를 쓴다 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | 이미지에 `curl` 도 `wget` 도 없다 | 탐침 파드나 Prometheus |
| 지표를 파이썬으로 자르다 죽었다 | 원본까지 같이 사라진다 | `wget` 원문을 먼저 본다 |
| `get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| 57800 카운터만 0 이다 | FD_SOCK2 가 아직 재연결을 안 했다 | 조금 기다렸다 다시 본다 |
| 해제했는데 2~3분째 안 붙는다 | 반대 노드 규칙이 남아 있다 | 두 노드 모두 `-t raw -S PREROUTING` |
| `a5-probe` 를 다시 못 만든다 | 앞선 실행의 파드가 그 이름으로 남아 있다 | `delete pod a5-probe --ignore-not-found` |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 파드 IP `10.42.1.77` 과 `10.42.0.42` 와 노드 배치, 뷰 ID `13`→`14`→`15`, 시도 ① 의 `pkts 0` 과 kube-router 가 되찾은 `num 1`, 시도 ② 의 `pkts 0`, 성공한 주입의 `19 2938` 과 이어서 `19 1096` 과 `21 3058`, 주입 `12:28:23` 과 `12:33:58` 과 `12:40:25` 와 해제 `12:44:37`, 단방향에서 `+75초` 의 `0/1` 과 `+100초` 의 복귀, 양방향에서 `+100초` 이후 `0/1` 고정과 ready 주소 하나, `coord = t` 둘, 양쪽 헬스체크의 `UP` 과 `DOWN`, `suspected=0.0` 과 `merge_events=1.0`, 복구 50초, `MergeView` 뷰 `15`, `ISPN100007` 다섯 캐시, `MergeView` 가 `03:33:49` 에 생기고 주입이 `03:33:58` 인 9초 간격.
- (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력. 가이드가 미검증으로 표시했다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `conntrack` 과 `iptables -F` 를 따로 치는 두 단계 형태도 이 실험대에서 치지 않았다. 반대 노드의 `filter FORWARD` 를 조회하는 명령은 원 가이드에 아예 없어서 확인표에 넣지 못했다.
- 증거가 비어 있는 곳 — 시도 ① 의 카운터를 담았어야 할 `02-injection-verify.txt` 가 원 시점에 0바이트로 저장됐다. 지금 그 파일에 있는 것은 사후 수집이고 원 시점의 `DROP` 규칙은 재현되지 않는다.
- 원 실행에 안 남은 것 — 주입 전 `vendor_cluster_size` 값. 파이썬 한 줄이 죽으면서 Prometheus 원본까지 함께 사라졌다.
- 이 절차가 재지 않은 것 — 분단 중에 세션이 어떻게 되는지는 재지 않았다(그것은 A-1 의 주제다). 여기서는 누가 살아남는가만 봤다.
<!-- body:end -->
@@ -0,0 +1,825 @@
---
id: cf2e783c-424b-4167-aa27-3bd6b5f46ee2
kind: SETUP
slug: reproduce-a6-latency-injection
title: flannel.1 에 200ms 를 넣고 커넥션 풀이 고갈되는 것을 본다
topic: losing-a-node-or-the-store
topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/cf2e783c-424b-4167-aa27-3bd6b5f46ee2/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-6
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# flannel.1 에 200ms 를 넣고 커넥션 풀이 고갈되는 것을 본다
postgres 가 보내는 패킷 중 노드를 건너가는 것만 200밀리초 지연시켜 Keycloak 한 대의 JDBC 커넥션 풀이 마르는 것까지 따라가는 절차다. 주입 셋 중 둘은 일부러 실패시키고, 걸렸는지는 `tc -s` 카운터로만 판정한다. 전 구간 약 30분.
## 관계
- **200 밀리초를 넣었더니 응답이 22.2 초가 됐다**
이 절차가 만드는 사건이고, 왜 200밀리초가 22초가 되는지는 그 기록이 결론으로 갖는다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
여기서 일부러 밟는 실패 둘이 그 아홉 건에 들어 있다.
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
스크립트가 찍은 「적용완료」를 커널의 답으로 읽은 것이 그 기록이 다루는 실패와 같은 종류다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
대조군을 같은 클러스터 안에 두고 `tc -s` 로 주입을 먼저 판정하는 이 순서가 그 기준을 따른다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
`kubectl``kc-lab-1` 에서 친다. `tc` 는 노드 자체를 건드리는 명령이라 `kc-lab-2` 에서 치고, postgres 가 그 노드에 있다. 아래 명령들은 가이드가 실제로 친 한 줄 형태 그대로이므로 `ssh kc-lab-2 '...'` 가 붙어 있다. 코드블록마다 어느 셸인지 붙여 두었다.
터미널은 둘을 연다. 하나는 부하와 측정, 하나는 이벤트 관찰이다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 주입 지점 | `flannel.1`(VXLAN 터널). `eth0` 은 없고 `enp1s0` 에는 파드 IP 가 안 보인다 |
| 주입 값 | `netem delay 200ms``prio` 의 3번 밴드에 |
| 고르는 기준 | `u32 match ip src $PG/32` — postgres 가 보내는 패킷만 |
| 탐침 파드 | `a6-probe``curlimages/curl:8.11.1`, `sleep 1800`, `--restart=Never` |
| 부하 | 동시 20건. 결과는 파드 안 `/tmp/load` 에 모은다 |
| 걸리는 시간 | 전 구간 약 30분 |
## 이 실험이 가르는 것
A-2 는 DB 를 완전히 세웠고 A-4 는 기계를 통째로 껐다. 둘 다 즉시 드러났다 — `503` 이 나오고 `up` 이 0 이 됐다. 실제 장애의 대부분은 느려지기만 하고, 느려짐은 헬스체크가 통과하므로 사망보다 진단하기 어렵다.
묻는 것은 하나다.
```text
200밀리초를 넣으면 애플리케이션은 200밀리초 느려지는가?
```
답은 아니고, 두 군데에서 곱해진다. 끝나면 이것들을 자기 화면에서 본다 — `eth0` 이라는 인터페이스가 없다는 것은 `ip -brief link` 에서 보고, 스크립트가 「적용완료」를 찍었는데 아무것도 안 걸린 것은 `tc -s qdisc` 카운터에서 본다. `enp1s0` 에서 파드 IP(Internet Protocol 주소)가 안 보이는 것은 VXLAN(Virtual Extensible LAN, 가상 확장 랜) 캡슐화에서, 200ms 가 1,872ms 가 되는 것은 두 노드 응답 시간 비교에서 드러난다. 동시 20건이 22.2초까지 계단으로 늘어나는 것은 상주 탐침이 모은 파일에서, 커넥션 획득에 20초를 기다린 요청은 `agroal_blocking_time_max_milliseconds` 에서 나온다. readiness 프로브가 같은 줄에 서서 타임아웃되는 것은 `kubectl get events` 에 찍히고, 예측했던 낙관적 락 충돌이 0건인 것은 Keycloak 로그에서 확인한다.
## 전제와 되돌리기
- `05-keycloak``06-observability` 가 끝나 있다.
- A-5 를 먼저 해 두면 좋다. 「주입을 넣은 것과 걸린 것은 다르다」가 여기서 세 번째로 나온다.
- `tc``kc-lab-2` 에서 친다(`ssh kc-lab-2`). postgres 가 그 노드에 있다.
:::warning
이 절차는 Keycloak 한 대를 느려지게 만든다. 파드가 재시작될 수 있고 readiness 가 빠진다. 실험대에서만 한다.
:::
### 1. 중단하는 방법을 먼저 읽어 둔다
**목적** — 어느 단계에서든 `flannel.1` 에 건 것을 한 줄로 전부 걷어낼 수 있게 해 둔다.
**지금 치는 명령이 아니다.** 아래 한 줄은 중간에 그만둘 때 치는 것이고, 여기서는 어디 있는지만 봐 둔다. 아직 아무것도 걸지 않았으므로 지금 치면 지울 것이 없다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄 — 읽어만 둔다"
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
```
지금 쳐서 확인할 것은 걸린 것이 없다는 쪽이다.
```bash label="[kc-lab-1] 지금 flannel.1 에 무엇이 걸려 있는지 본다"
ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'
```
**예상 결과** — `qdisc noqueue 0: root refcnt 2` 다. 이 값이 주입 전의 출발점이고, 걷어낸 뒤에 같은 줄이 다시 나오면 원상복구된 것이다. `netem` 이 지금 보이면 앞 실험이 안 걷고 끝낸 것이므로 위의 `del` 을 먼저 친다. 걸린 것이 없는 상태에서 `del` 을 쳤을 때의 출력은 이 실험대에 기록이 없다.
**왜 필요한가** — 이 한 줄이 세 가지를 다 지운다. `prio` qdisc 와 그 아래 `netem` 과 filter 이고, `root` 를 지우면 자식이 함께 사라진다.
**문제가 생기면** — `tc` 는 노드 자체를 건드리는 명령이라 게스트 셸이 필요하다. 따라 하는 사람은 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 `sudo tc ...` 를 칠 수 있고, 그러면 한 줄에 SSH(Secure Shell, 원격 셸) 접속과 원격 셸의 인용이 겹치지 않는다. 이 두 단계 형태는 이 실험대에서 치지 않았다.
## 주입 전에 같은 명령으로 먼저 본다
배치를 먼저 확인해야 이 절차가 성립한다. 대조군이 같은 클러스터 안에 있는 설계이기 때문이다. 순서는 이렇다.
```text
파드 배치 → 상주 탐침 → 단일 요청 → 20회 반복 → 커넥션 풀 지표
```
### 1. postgres 와 두 Keycloak 이 어느 노드에 있나
**무엇을 확인하는가** — postgres 와 `keycloak-0` 이 같은 노드이고 `keycloak-1` 만 노드를 건너는지.
```bash label="[kc-lab-1] ① 배치와 IP"
kubectl -n keycloak-lab get pods -o wide
```
```bash label="[kc-lab-1] ② 뒤에서 계속 쓸 세 값을 셸 변수에 담는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].status.podIP}')
echo "K0=$K0 K1=$K1 PG=$PG"
```
**출력에서 답이 되는 것** — 세 파드의 `NODE` 열이다. 실측은 이렇다.
```text
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-1` 의 DB 접근만 느려지고 `keycloak-0` 은 그대로다.
```text
kc-lab-2 kc-lab-1
┌──────────────────┐ ┌──────────────────┐
│ postgres │ │ keycloak-1 │
│ keycloak-0 │ │ │
│ └─ cni0 로 직행 │◀─ VXLAN ──▶│ └─ 오버레이 경유 │
└──────────────────┘ └──────────────────┘
지연 없음 여기만 느려진다
```
대조군이 같은 실험 안에 있으므로 파드를 두 개 더 띄울 필요도, 다른 시간대와 비교할 필요도 없다. 배치가 다르면 이 절차는 성립하지 않는다 — 두 Keycloak 이 모두 DB 와 다른 노드에 있으면 대조군이 없고, 모두 같은 노드에 있으면 시험군이 없다.
### 2. 상주 탐침을 띄운다
**목적** — 같은 요청을 수십 번 반복할 수 있는 파드를 하나 띄우고, 비밀번호를 값으로 찍지 않고 넘긴다.
```bash label="[kc-lab-1] ① 탐침 파드를 띄운다"
kubectl -n keycloak-lab run a6-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 1800
```
```bash label="[kc-lab-1] ② 뜰 때까지 기다린다"
kubectl -n keycloak-lab wait --for=condition=Ready pod/a6-probe --timeout=120s
```
```bash label="[kc-lab-1] ③ 값이 아니라 길이만 확인한다"
kubectl -n keycloak-lab exec a6-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW=${#PW}자"'
```
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
```text
K0=10.42.1.77 K1=10.42.0.42 PW=32자
```
**왜 필요한가** — Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 비밀번호는 명령 치환으로 넘어가므로 화면에 안 나오고, 확인할 때도 길이만 본다. `PW=0자` 면 시크릿이 안 넘어간 것이고 그 상태로 재면 전부 `401` 을 재게 된다.
**문제가 생기면** — 탐침의 `K0` 와 `K1` 은 만들 때 고정된다. Keycloak 파드가 재시작되면 IP 가 바뀌고 탐침의 값이 낡으므로, 그때는 탐침을 지우고 다시 만든다. 이걸 놓치면 「아무 데도 안 닿음」을 「지연」으로 읽는다. 그리고 부하를 `kubectl run --rm -i` 로 주면 안 된다 — 원 실행이 그렇게 했다가 동시 20건의 출력을 잃었다. 파드가 만들어지고 지워지는 사이에 stdout 을 붙잡는 경주가 되고, 20줄 중 일부만 도착하거나 아예 끊긴다.
### 3. 요청 하나를 읽는 형태로 먼저 친다
**무엇을 확인하는가** — 시간이 연결에 드는지 첫 바이트까지 드는지.
```bash label="[kc-lab-1] ① 구간별 시간을 함께 찍는다"
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` 다. 모양은 이렇고 숫자는 환경마다 다르다.
```text
connect 0.001 ttfb 0.065 total 0.066
```
| 값 | 무엇의 시간인가 |
|---|---|
| `time_connect` | 탐침에서 Keycloak 까지의 TCP 연결. 이 절차에서 거의 안 변한다 |
| `time_starttransfer` | 첫 바이트까지 = Keycloak 이 DB 와 대화한 시간. 여기가 폭발한다 |
**이 결과가 뜻하는 것** — 지연은 탐침과 Keycloak 사이가 아니라 Keycloak 과 DB 사이에 넣으므로 `connect` 는 그대로고 `ttfb` 만 는다. 주입 후에 이 두 값을 다시 보면 어디에 지연이 걸렸는지 한눈에 판정된다. 응답이 `401` 이나 `400` 이면 `-o /dev/null` 을 빼고 본문을 본다.
### 4. 20회를 반복해 원본을 파일에 모은다
**무엇을 확인하는가** — 주입 전 두 노드의 응답 시간.
```bash label="[kc-lab-1] ① keycloak-1 에 20회"
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 label="[kc-lab-1] ② 원본을 먼저 본다"
kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1
```
```bash label="[kc-lab-1] ③ 그다음 줄여서 본다"
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` 로 바꿔 대조군도 똑같이 잰다.
**출력에서 답이 되는 것** — 두 노드의 평균이다.
```text
=== 기준선 지연 — 각 노드에서 로그인 20회 ===
keycloak-0 평균 70 ms
keycloak-1 평균 66 ms
```
**이 결과가 뜻하는 것** — 두 값이 비슷하고 지금은 `keycloak-1` 이 오히려 4ms 빠르다. VXLAN 을 건너는 쪽이 더 빠를 수도 있는 수준의 차이이므로, 뒤에 나올 28배가 의심의 여지 없이 주입 탓이 된다. 평균만 보면 한 건이 튄 것을 놓치므로 ② 를 건너뛰지 않는다. 그리고 횟수를 주입 전후로 똑같이 맞춘다 — 이 측정은 20회로 쟀는데 해설 문서의 재현 절차에는 15회로 적혀 있고, 횟수가 다르면 평균도 달라진다.
### 5. 커넥션 풀 지표에 무엇이 있는지 미리 본다
**무엇을 확인하는가** — `agroal_*` 지표의 이름과 지금 값.
```bash label="[kc-lab-1] ① 지표 이름 목록"
kubectl -n keycloak-lab exec a6-probe -- sh -c \
'curl -s "http://$K1:9000/metrics" | grep "^agroal_"'
```
```text
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
```
**출력에서 답이 되는 것** — 열두 줄 가운데 뒤에서 쓰는 넷이다.
| 지표 | 무엇을 말하는가 |
|---|---|
| `blocking_time_max` | 커넥션을 받으려고 가장 오래 기다린 시간 |
| `max_used_count` | 풀이 최대 몇 개까지 늘었나 |
| `awaiting_count` | 지금 줄 서 있는 요청 수 |
| `active_count` | 지금 쓰이고 있는 커넥션 수 |
**이 결과가 뜻하는 것** — `agroal_*` 이 JDBC 커넥션 풀 지표다(Agroal 은 Quarkus 의 풀 구현이다). `awaiting_count` 와 `active_count` 는 순간값이라 부하가 끝나면 0 으로 돌아가므로 부하 중에 읽어야 보이고, `blocking_time_max` 는 누적이라 나중에 읽어도 남는다. 위 목록은 알파벳순으로 `destroy_count_total` 에서 끊겨 있는데 원 실행이 앞부분만 남긴 것이고, 실제로는 뒤에 `agroal_max_used_count` 같은 것이 더 있다. 증거 파일이 짧다고 지표가 없는 것은 아니다.
## 주입
주입은 세 번이고 앞의 둘은 일부러 실패한다.
### 1. 시도 ① — eth0
**목적** — 인터넷 예제가 전부 쓰는 이름을 그대로 써 보고 무엇이 나오는지 본다.
```bash label="[kc-lab-1] ① 예제 그대로"
ssh kc-lab-2 'sudo tc qdisc add dev eth0 root handle 1: prio'
```
**예상 결과**
```text
Cannot find device "eth0"
```
**왜 필요한가** — 한 줄이면 끝날 일인데 원 실행은 이것을 스크립트로 돌렸고, 그 출력이 이랬다.
```text
=== 주입: 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` 는 네 번 다 실패했는데 스크립트는 그대로 다음 절로 넘어갔고 문서에는 시각까지 찍혔다. 명령의 성공을 「에러가 안 보인다」로 판정하면 안 되고, 손으로 한 줄씩 치면 이 실수를 할 수 없다.
**문제가 생기면** — 인터페이스 이름을 확인한다.
```bash label="[kc-lab-1] ② 이 게스트에 무엇이 있나"
ssh kc-lab-2 'ip -brief link'
```
```text
flannel.1 UNKNOWN a6:b2:62:04:c1:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
cni0 UP 5a:77:1a:e2:b0:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
```
게스트의 물리 NIC(Network Interface Card, 네트워크 카드) 이름은 `enp1s0` 이고 `eth0` 이 없다.
| 이름 | 무엇 |
|---|---|
| `enp1s0` | 게스트의 물리(가상) NIC(Network Interface Card, 네트워크 카드). 노드 간 실제 트래픽이 나가는 곳 |
| `flannel.1` | VXLAN 터널. 노드를 건너는 파드 트래픽이 여기로 들어간다 |
| `cni0` | 노드 안 브리지. 같은 노드 파드끼리는 여기서 끝난다 |
Debian 클라우드 이미지는 예측 가능한 인터페이스 이름을 쓴다.
```text
enp1s0
│ │ └─ s0 : slot 0
│ └──── p1 : PCI bus 1
└────── en : ethernet
```
이름이 하드웨어 위치에서 나오므로 NIC 순서가 바뀌어도 이름이 안 바뀌고, 그 대신 `eth0` 이라고 적힌 인터넷의 모든 예제가 안 돈다. `flannel.1` 의 상태가 `UNKNOWN` 인 것은 정상이다 — 터널 장치는 캐리어 개념이 없어서 `UP` 대신 `UNKNOWN` 으로 보고한다.
### 2. 시도 ② — enp1s0. 거는 명령이 없다
**목적** — 이름만 고치면 되는지 확인한다.
**이 절에는 주입 명령이 없다.** `enp1s0` 로 `tc` 를 거는 줄은 원본 가이드에 없다(unknown) — 원 실행은 `eth0` 이 실패한 뒤 곧바로 `flannel.1` 로 갔다. 여기서 실제로 칠 수 있는 것은 아래 미검증 `tcpdump` 두 줄뿐이고, 왜 `enp1s0` 이 답이 아닌지는 구조에서 나온다. 두 줄을 건너뛰어도 3번으로 넘어가는 데 지장이 없다.
노드 간 파드 통신은 flannel VXLAN 으로 캡슐화된다.
```text
원래 패킷: 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 만 있다. 눈으로 확인하는 두 줄을 가이드가 미검증으로 표시했다.
```bash label="[kc-lab-1] ① 미검증 — 물리 쪽에는 노드 IP 만 보인다"
ssh kc-lab-2 'sudo tcpdump -i enp1s0 -n -c 5 udp port 8472'
```
```bash label="[kc-lab-1] ② 미검증 — 터널 쪽에는 파드 IP 가 보인다"
ssh kc-lab-2 'sudo tcpdump -i flannel.1 -n -c 5 host 10.42.1.76'
```
**예상 결과** — 앞쪽에서는 노드 IP 사이의 UDP 8472 만 보이고 `10.42.x.x` 는 안 보인다. 뒤쪽 터널에서는 파드 IP 가 보인다.
| 인터페이스 | 파드 IP 가 보이나 | 무엇을 지연시키게 되나 |
|---|---|---|
| `cni0` | 보인다 | 같은 노드 안 통신만 |
| `flannel.1` | 보인다 (캡슐화 직전) | 노드를 건너는 파드 통신 |
| `enp1s0` | 안 보인다 | 노드 간 모든 것 (SSH·k3s 포함) |
**왜 필요한가** — `enp1s0` 에 `netem` 을 root 로 걸면 `kubectl` 도 SSH 도 같이 느려져서 무엇이 원인인지 못 가린다.
**문제가 생기면** — 원 실행에는 이 확인이 없다. `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔으므로 「`enp1s0` 에 걸면 0 패킷」이라는 출력 원문은 이 실험에 없고, 구조에서 나온 결론이다.
### 3. 성공한 주입 — flannel.1 에 세 줄
**목적** — postgres 가 보내는 패킷만 골라 200ms 지연시킨다.
한 줄씩 친다. 앞 줄이 실패하면 뒤 줄은 붙을 곳이 없어서 다른 에러를 낸다.
```bash label="[kc-lab-1] ① 밴드 3개짜리 분류기를 만든다"
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 root handle 1: prio"
```
```bash label="[kc-lab-1] ② 3번 밴드에 200ms 지연을 붙인다"
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 parent 1:3 handle 30: netem delay 200ms"
```
```bash label="[kc-lab-1] ③ 출발지가 postgres 인 패킷을 3번 밴드로 보낸다"
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"
```
```bash label="[kc-lab-1] ④ 주입 시각"
date '+%H:%M:%S 주입'
```
**예상 결과** — 세 줄 다 아무것도 찍지 않는다. 걸렸는지는 다음 절의 카운터가 답한다.
**왜 필요한가** — 세 줄이 나뉘어 있는 까닭은 `tc` 의 계층 구조다.
```text
qdisc (큐 규율) 인터페이스에 붙는 패킷 스케줄러
├─ prio 우선순위 밴드 3개로 나눈다
│ ├─ 1:1 (기본)
│ ├─ 1:2 (기본)
│ └─ 1:3 ← 여기에 netem 을 붙인다
└─ filter 어떤 패킷을 어느 밴드로 보낼지
```
| 줄 | 하는 일 |
|---|---|
| `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번 밴드로 보낸다 |
`netem` 을 root 에 바로 붙이면 모든 트래픽이 느려진다. `prio` 와 `filter` 를 쓰면 고른 트래픽만 느려지고, 이 절차는 postgres 가 보내는 것만 골라야 하므로 세 단계가 필요하다.
**문제가 생기면** — 지연이 양쪽 다 늘었으면 `netem` 을 `root` 에 직접 붙인 것이다. 중단 명령으로 걷어내고 세 줄을 다시 친다.
## 주입 검증
### 1. 시도 ① 은 값을 찍기만 하고 판정하지 않아 그냥 지나갔다
시도 ① 은 에러를 냈는데도 그대로 넘어갔고, 그 상태에서 잰 「검증」이 이랬다.
```text
=== [검증] 지연이 실제로 걸렸는가 — 두 노드 비교 ===
keycloak-0 평균 43 ms 최대 64 ms
keycloak-1 평균 47 ms 최대 70 ms
```
두 노드가 여전히 같고, 그것이 「안 걸렸다」는 신호였다. 검증 절이 값을 찍기만 하고 판정하지 않으면 이렇게 지나간다.
### 2. 성공한 주입 뒤에는 카운터를 본다
```bash label="[kc-lab-1] ① 넣은 직후의 카운터"
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1'
```
```text
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 label="[kc-lab-1] ② 요청을 한 번 보낸다"
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 label="[kc-lab-1] ③ 다시 센다"
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem'
```
```text
=== [검증] 필터에 패킷이 걸리는가 ===
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` 이므로 실제로 지연 밴드를 통과했다.
| 상태 | 뜻 | 할 일 |
|---|---|---|
| 부하 전 `0 pkt` | 아직 트래픽이 없다 | 요청을 한 번 보내고 다시 센다 |
| 부하 후에도 `0 pkt` | 필터가 아무것도 못 잡았다 | IP·인터페이스·방향을 다시 본다 |
| `pkt` 이 는다 | 걸렸다 | 관찰로 넘어간다 |
| `dropped` 가 는다 | `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
A-1 과 A-5 에서 나온 것과 같은 교훈이 세 번째로 나왔다 — 주입을 넣은 것과 걸린 것은 다르다. 필터 자체를 보는 줄은 가이드가 미검증으로 표시했다.
```bash label="[kc-lab-1] ④ 미검증 — 필터 목록"
ssh kc-lab-2 'sudo tc filter show dev flannel.1'
```
## 관찰
### 1. 단일 요청 — connect 는 그대로고 ttfb 만 폭발한다
주입 전에 친 것과 똑같은 명령을 다시 친다.
```bash label="[kc-lab-1] ① 구간별 시간"
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"'
```
그다음 20회 반복으로 두 노드를 잰다.
```text
=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
keycloak-0 평균 41 ms 최대 57 ms
keycloak-1 평균 1872 ms 최대 1887 ms
```
`keycloak-1` 이 66ms 에서 1,872ms 로 28배가 됐다. 대조군도 변했다 — `keycloak-0` 은 70ms 에서 41ms 로 41% 빨라졌다. 주입과 무관한 변동(JIT(Just-In-Time 컴파일) 워밍업, 캐시)이며, 해설 문서가 처음에 「영향 없음」이라고 쓴 것은 부정확했다. 자릿수가 달라 결론은 유지되지만 대조군이 안 변한다고 가정하면 안 된다.
왜 200ms 가 1,872ms 가 되는가는 A-0 에서 잡은 로그인 트랜잭션의 SQL 이 답한다.
```text
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
```
왕복이 아홉 번이다.
```text
200 ms × 9 왕복 ≈ 1,800 ms 실측 1,872 ms
```
`9` 는 SQL 목록을 센 값이고 패킷을 추적한 값이 아니다. 자릿수가 맞는다는 것까지가 이 계산이 말할 수 있는 범위이며, 왕복 수를 확정하려면 `tc -s` 의 패킷 수를 요청 수로 나누거나 패킷 캡처가 필요하다. 그래도 네트워크 지연이 왕복 횟수만큼 증폭된다는 것까지는 이 측정이 뒷받침한다. 「DB 가 200ms 느려졌다」는 「애플리케이션이 200ms 느려졌다」가 아니고, 쿼리 수를 줄이는 것이 지연 환경에서 결정적인 까닭이 여기 있다.
### 2. 동시 부하가 이 절차의 본 시험이다
순차로 20번 돌리면 큐잉이 재현되지 않는다. 백그라운드로 띄우고 `wait` 하며, 결과는 파드 안 파일에 모은다.
```bash label="[kc-lab-1] ① 동시 20건"
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 label="[kc-lab-1] ② 다 모였는지부터 센다"
kubectl -n keycloak-lab exec a6-probe -- cat /tmp/load > /tmp/load.txt
wc -l /tmp/load.txt
```
```bash label="[kc-lab-1] ③ 원본을 본다"
cat /tmp/load.txt
```
```bash label="[kc-lab-1] ④ 상태 코드와 시간을 나눠 본다"
awk '{print $1}' /tmp/load.txt | sort | uniq -c
awk '{print $2}' /tmp/load.txt | sort -g
```
`20` 이 아니면 수집이 샌 것이고, 그 상태의 숫자는 해석하지 않는다.
```text
=== 동시 부하 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 label="[kc-lab-1] ⑤ 정렬 방식을 갈라 본다"
sort /tmp/load.txt # 사전순 — 10.5 가 3.4 앞에 온다
sort -g /tmp/load.txt # 수치순 — 이걸 써야 한다
```
시간 값을 정렬할 때는 `sort -g` 를 쓴다. 이걸 놓치면 최대값을 잘못 읽는다. 숫자를 순서대로 놓으면 계단이 된다.
```text
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` 만 보는 감시는 이 장애를 못 본다.
### 3. 커넥션 풀 지표는 부하가 끝나자마자 읽는다
```bash label="[kc-lab-1] ① 부하 직후에 읽는다"
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)"'
```
```text
=== 부하 직후 커넥션 풀 ===
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초를 보지 못한다.
이 관측 스택에는 히스토그램 지표가 없어서 그 사이의 분포는 안 나온다. 가이드가 남겨 둔 쿼리는 다음 한 줄이다.
```promql label="[Prometheus 질의] 미검증 — 이 실험대에 이 지표가 없어 치지 않았다"
# 있으면 좋았을 것
histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m]))
```
### 4. 헬스체크가 같은 줄에 선다
```bash label="[kc-lab-1] ① 최근 이벤트"
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
```
```bash label="[kc-lab-1] ② 파드 상태"
kubectl -n keycloak-lab get pods
```
```text
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 프로브 자체가 타임아웃됐다. 헬스체크도 같은 커넥션 풀 줄에 서므로 연쇄가 이렇게 된다.
```text
DB 가 느려진다
요청이 커넥션을 오래 붙잡는다
커넥션 풀이 고갈된다
새 요청이 줄을 선다 (최대 20초)
헬스체크도 줄에 선다 → 타임아웃 → NotReady
그 노드가 로드밸런서에서 빠진다
★ 남은 노드로 트래픽이 몰린다 → 그 노드도 같은 길을 간다
```
마지막 화살표에서 느려짐이 전파된다. A-2(DB 완전 정지)는 즉시 `503` 으로 드러나 오히려 명확했고, 느려짐은 살아 있는 노드를 하나씩 무너뜨린다.
해설 문서는 이 연쇄에서 구성 규칙 두 가지를 끌어냈다.
| 알게 된 것 | 구성에서 무엇을 정하나 |
|---|---|
| 커넥션 풀에서 한 번 더 곱해진다 | 풀 크기와 타임아웃이 장애 반경을 정한다 |
| 헬스체크도 줄에 선다 | 프로브 타임아웃이 풀 대기보다 짧아야 격리가 제때 된다 |
헬스체크 쪽이 이 절차에서 실제로 일어난 일이다.
### 5. 빗나간 예측도 하나 남았다
계획서에는 이렇게 적혀 있었다.
> 낙관적 락 충돌 증가 — 트랜잭션이 길어져 `VERSION` 충돌이 늘어야 한다
지연 구간의 로그를 세는 줄을 가이드가 미검증으로 표시했다. 원 실행의 정확한 패턴이 기록에 없다.
```bash label="[kc-lab-1] ① 미검증 — 충돌 로그를 센다"
kubectl -n keycloak-lab logs keycloak-1 --since=20m \
| grep -icE 'optimistic|StaleState|version.*conflict'
```
```text
=== 낙관적 락 충돌이 늘었는가 — 지연 중 로그 ===
관련 로그 줄수: 0
```
하나도 없었고 까닭이 명확하다.
```text
로그인 → 매번 새 세션 행을 INSERT → 다툴 상대가 없다
refresh → 같은 세션 행을 UPDATE → 여기서 다툰다
```
충돌은 같은 행을 동시에 고칠 때만 일어나므로 로그인 부하로는 재현되지 않는다. 예측이 빗나간 뒤에야 연산이 INSERT 라는 것이 보였고, 틀린 이유가 락 구현이 아니라 연산의 종류에 있었다. B-3(refresh 토큰 경쟁)의 영역이고, 거기서 지연을 함께 주면 충돌률이 올라갈 것이라고 가이드는 적는다. 예측을 적어 두지 않았다면 「충돌이 없네」 하고 넘어갔다.
## 복구와 원상복구 확인표
### 1. 지연을 걷어낸다
**목적** — `flannel.1` 의 `root` qdisc 를 지워 `prio` 와 `netem` 과 filter 를 한꺼번에 없앤다.
```bash label="[kc-lab-1] ① 해제 시각"
date '+%H:%M:%S 해제'
```
```bash label="[kc-lab-1] ② root 를 지운다"
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
```
```bash label="[kc-lab-1] ③ 무엇이 남았는지 본다"
ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'
```
**예상 결과**
```text
=== 지연 해제 ===
해제완료
qdisc noqueue 0: root refcnt 2
```
`noqueue` 이므로 `prio` 도 `netem` 도 없다.
**왜 필요한가** — `root` 를 지우면 그 아래 자식 qdisc 와 filter 가 같이 사라진다. 하나씩 지우면 filter 를 빠뜨리기 쉽다.
**문제가 생기면** — 시도 ① 이 `enp1s0` 에 무언가 남겼을 수 있으므로 그쪽도 본다.
### 2. 회복을 같은 명령으로 확인한다
**목적** — 주입 전과 같은 20회 반복 측정을 다시 쳐서 자릿수가 돌아왔는지 본다.
회복은 20회 반복 측정 명령을 그대로 다시 쳐서 본다. 그 명령의 첫 줄이 `rm -f /tmp/base-k1` 이므로 파일은 새로 만들어진다. 두 노드 다 잰다 — 같은 명령이어야 비교가 된다.
**예상 결과**
```text
=== 회복 확인 ===
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 를 흔들었을 뿐 파드를 죽이지는 않았고, 커넥션 풀도 스스로 정상화됐다.
**왜 필요한가** — `agroal_blocking_time_max_milliseconds` 는 누적이라 `20000` 인 채로 남는다. 파드를 재시작해야 0 이 되는데 그대로 두는 편이 낫다 — 이 노드가 한 번 20초를 기다린 적이 있다는 기록이다.
**문제가 생기면** — 탐침 파드를 지운다.
```bash label="[kc-lab-1] 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
```
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| qdisc | `ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'` | `noqueue` |
| (물리 쪽도) | `ssh kc-lab-2 'sudo tc qdisc show dev enp1s0'` | 시도 ① 잔재가 없어야 한다 |
| 응답 시간 | 20회 반복 측정 | 주입 전과 같은 자릿수 |
| 파드 | `kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
| Service | `kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 |
| 풀 | `agroal_awaiting_count` · `agroal_active_count` | `0` |
| 탐침 파드 | `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` |
## 막히면
이 표는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `Cannot find device "eth0"` | 이 게스트의 NIC 는 `enp1s0` 이다 | `ip -brief link` |
| 스크립트가 「적용완료」인데 지연이 없다 | 성공 메시지는 스크립트가 찍은 것 | `tc -s qdisc` 카운터 |
| `enp1s0` 에 걸었는데 안 걸린다 | VXLAN 안에 파드 IP 가 숨어 있다 | `flannel.1` 에 건다 |
| `Sent 0 pkt` | 부하 전이면 정상. 부하 후면 필터가 틀렸다 | 요청 한 번 보내고 다시 센다 |
| 지연이 양쪽 다 늘었다 | `netem` 을 `root` 에 직접 붙였다 | `prio` + `filter` 로 골라 낸다 |
| `kubectl` 이나 SSH 까지 느려졌다 | `enp1s0` 에 걸었다 | `tc qdisc del dev enp1s0 root` |
| 20줄 중 몇 줄만 온다 | `kubectl run --rm -i` 로 동시 실행하면 stdout 이 샌다 | 상주 파드 + 파일 |
| 최대값이 `9.1` 로 보인다 | `sort` 가 사전순이다 | `sort -g` |
| `blocking_time` 이 0 이다 | 부하가 끝나고 한참 뒤에 읽었다 | 부하 직후에 읽는다 |
| `awaiting_count` 가 늘 0 이다 | 순간값이다 | 부하가 도는 중에 읽는다 |
| 로그인이 전부 `401` | `PW` 가 안 넘어갔다 | `exec a6-probe -- sh -c 'echo ${#PW}'` |
| 갑자기 아무 데도 안 닿는다 | 파드 IP 가 바뀌었다 | 탐침을 지우고 다시 만든다 |
| 이벤트가 과거 것과 섞인다 | 이벤트는 한 시간 전 것도 남는다 | `Age` 를 먼저 본다 |
| 대조군도 값이 변했다 | 정상이다. JIT 와 캐시 변동 | 자릿수로 판정한다 |
| `dropped` 가 늘어난다 | `netem` 의 `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 파드와 postgres 의 노드 배치, 주입 전 평균 `70 ms` 와 `66 ms`, `Cannot find device "eth0"` 네 줄 사이에 낀 `적용완료` 와 주입 시각 `13:14:55`, 그 상태의 「검증」 값 `43 ms` 와 `47 ms`, `ip -brief link` 의 `flannel.1` 과 `cni0`, 넣은 직후의 `Sent 0 bytes 0 pkt` 와 부하 뒤의 `Sent 18388 bytes 150 pkt`, 주입 뒤 `41 ms` 와 `1872 ms`, 동시 20건의 스무 줄 전부와 `22.230871` 까지의 계단, `blocking_time_max 20000.0` 과 `max_used_count 19.0` 과 `acquire_count_total 672.0` 과 `blocking_time_average 281.0`, 이벤트 세 줄과 `89s`·`32m`·`52m`, 낙관적 락 로그 `0`, 해제 뒤 `noqueue` 와 `43 ms` 와 `51 ms`, `agroal_*` 지표 이름 열두 개.
- (unknown) `enp1s0` 과 `flannel.1` 에 각각 거는 `tcpdump` 두 줄, `tc filter show`, 낙관적 락 로그를 세는 `grep -icE` 줄. 가이드가 셋 다 미검증으로 표시했다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `tc` 를 치는 두 단계 형태도 이 실험대에서 치지 않았다.
- 구조에서 나온 결론이고 출력이 없는 것 — 「`enp1s0` 에 걸면 0 패킷」. 원 실행은 `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔다.
- 센 것이고 잰 것이 아닌 것 — 왕복 `9` 는 A-0 이 잡은 SQL 목록을 센 값이고 패킷을 추적한 값이 아니다. `200 ms × 9 ≈ 1,800 ms` 와 실측 `1,872 ms` 의 자릿수가 맞는다는 것까지가 이 계산의 범위다.
- 증거 파일이 잘려 있는 것 — `agroal_*` 목록이 알파벳순으로 `destroy_count_total` 에서 끊겨 있다. 뒤에 쓰는 `agroal_max_used_count` 는 그 목록에 안 보이지만 부하 뒤 출력에는 있다.
- 처음 쓴 것이 부정확했던 곳 — 해설 문서의 「대조군 영향 없음」. 대조군은 `70 ms` 에서 `41 ms` 로 41% 빨라졌다.
- 이 절차가 재지 않은 것 — 응답 시간 분포. 관측 스택에 히스토그램 지표가 없어 평균 `281ms` 와 최대 `20,000ms` 사이에 무엇이 있었는지는 모른다. 낙관적 락 충돌도 로그인 부하로는 재현되지 않아 B-3 으로 넘겼다.
<!-- body:end -->
@@ -0,0 +1,150 @@
---
kind: CASE
slug: a-deploy-hook-closed-the-gap-to-two-seconds
title: deploy 훅 하나가 그 공백을 1~2초로 줄였다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
assets:
- key: d4a-hook-effect
file: ../../../final/assets/d4a-hook-effect/d4a-hook-effect.svg
evidence:
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
- ../../../final/evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt
- ../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt
---
# deploy 훅 하나가 그 공백을 1~2초로 줄였다
훅을 넣자 갱신에서 서빙까지가 1~2초로 줄었고, reload 를 부른 것도 사람이 아니라 certbot 의 deploy 훅이었다. 훅이 없던 같은 구간은 38분 25초였다. 그 1~2초를 재려면 시계 왜곡 106초를 보정해야 했고, certbot 은 훅이 성공했을 때도 로그에 error 라는 낱말을 찍는다.
## 관계
- **새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다**
그 실험이 공백을 재고 처방을 적었고, 이 실험이 그 처방을 넣어 전후를 같은 방법으로 견줬다.
- **reload 를 사람이 아니라 deploy 훅이 부르게 한다**
훅을 넣기 전후의 이 두 값이 그 결정이 근거로 삼은 측정이다.
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
certbot 이 성공한 훅에도 error 를 찍는 것을 여기서 보고 그 기준을 세웠다.
## 문제
앞선 실험은 갱신과 서빙 사이의 공백 2305초를 재고, reload 를 부르는 경로 셋이 모두 비어 있다는 원인까지 확정했다. 고치는 방법도 적었다. certbot 의 renewal-hooks 가운데 deploy 디렉터리에 reload 스크립트 하나를 넣는다는 것이었다.
그 처방은 넣어 보지 않은 상태였다. 훅을 넣으면 공백이 실제로 사라지는지, 그 reload 가 진행 중이던 요청에 무엇을 하는지는 재지 않은 채였다. 처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 적는 것은 이 실험대가 경계해 온 실수라, 훅을 넣는 일을 실험 하나로 따로 떼어 냈다.
## 결론
훅을 넣자 갱신에서 서빙까지가 1~2초가 됐고, reload 를 부른 것은 certbot 의 deploy 훅이었다.
갱신에서 서빙까지 : 훅 없음 2305초 = 38분 25초, 훅 있음 1~2초
reload 를 부른 것 : 훅 없음 사람, 훅 있음 certbot deploy 훅
사람이 건 reload 중 새 연결 8856건 : 전부 200
p95 : reload 직전 205.7ms, 직후 204.3ms
전송 12초째에 reload 를 맞은 42초짜리 요청 : 845361바이트를 온전히 받았다. 연결수 1
훅이 거는 reload 가 무중단인가 : 재지 않았다
certbot 은 훅이 성공했을 때도 Hook deploy-hook ran with error output 이라고 찍는다. 내용은 nginx 의 types_hash 경고가 stderr 로 나간 것이고, 같은 출력 안에 test is successful 과 signal process started 가 들어 있다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 읽는다.
1~2초를 재려면 시계 보정이 먼저다. test-server 는 NTP 가 꺼져 있어 106초 빨랐고, 보정하지 않고 그냥 빼면 훅이 발급보다 107초 뒤로 보인다 — 참값 1~2초보다 약 106초 어긋난 값이다. 보정을 반대쪽에 걸면 음수 지연이 나오는데, 훅은 갱신이 끝나야 돌기 때문에 그런 순서는 성립하지 않는다.
## 검증 환경
호스트 : test-server, Arch Linux, 12GB, WiFi only
TLS 종단 : 호스트 nginx, Let's Encrypt 인증서, traefik 으로 프록시
넣은 훅 : certbot 의 renewal-hooks 가운데 deploy 디렉터리에 reload 스크립트 하나
갱신 방식 : 강제 갱신. 타이머가 스스로 도는 갱신은 아니다
시계 : test-server 가 106초 빠르다. dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차
보정의 교차 기준 : 새 인증서에 박힌 SCT 두 개. CT 로그가 자기 시계로 서명한 값이다
측정일 : 2026-09-04
## 재현 조건
1. certbot 의 renewal-hooks 가운데 deploy 디렉터리에 nginx 설정을 검사하고 reload 하는 스크립트를 넣는다.
2. 강제 갱신을 걸기 전에 nginx 의 마스터와 워커 PID 를 읽어 둔다.
3. 새 연결을 0.2초 간격으로 보내는 폴링과, 845KB 짜리 응답을 20k/s 로 느리게 받는 요청 하나를 함께 띄운다.
4. 그 요청이 전송 중일 때 강제 갱신을 건다.
5. 갱신이 끝나면 마스터와 워커 PID 를 다시 읽어 마스터가 유지되고 워커만 바뀌었는지 본다.
6. certbot 출력에서 훅 실행 줄을 찾아 error 라는 낱말이 실패를 뜻하는지 내용을 열어 확인한다.
7. 새 인증서의 notBefore 를 발급 시각으로 쓰지 않는다. 1시간 백데이트를 되돌리고, 두 시계의 왜곡을 재서 보정한 뒤 SCT 와 견준다.
8. 느리게 받던 요청이 몇 바이트를 받고 끝났는지, 연결을 몇 번 맺었는지 확인한다.
## 본문
<!-- body:start -->
## 훅을 어느 디렉터리에 넣나
certbot 은 갱신 과정의 세 시점에 사용자가 넣어 둔 스크립트를 실행해 준다. 세 디렉터리의 차이는 실행 조건이다.
| 어느 디렉터리에 넣나 | 언제 도나 |
|---|---|
| `deploy/` | 실제로 갱신된 인증서가 있을 때만. `RENEWED_LINEAGE` 가 있을 때 돈다 |
| `post/` | 갱신 여부와 무관하게 매번 |
이 호스트의 타이머는 하루 두 번 돈다. `post/` 에 reload 를 넣으면 갱신이 없는 날에도 하루 두 번 워커가 교체되고, 워커가 바뀔 때마다 keep-alive 연결이 끊긴다. `deploy/` 는 갱신이 실제로 일어난 날에만 돌므로 reload 를 걸 곳은 그쪽이다. 넣은 것은 nginx 설정을 먼저 검사하고 통과하면 reload 신호를 보내는 스크립트 하나다.
검사를 앞에 둔 것은 설정이 깨진 상태로 reload 신호를 보내면 마스터가 새 워커를 못 띄우기 때문이다. 검사에서 걸리면 reload 가 아예 가지 않고 옛 워커가 그대로 서비스를 계속한다 — 인증서는 안 바뀌지만 사이트가 내려가지는 않는다. `reload` 대신 `restart` 를 쓰지 않은 까닭도 같다. 이 호스트의 `nginx.service``Restart=on-failure``RestartUSec=100ms` · `StartLimitBurst=5` · `StartLimitIntervalUSec=10s` 라, 설정이 깨진 채 restart 를 걸면 10초 안에 5번 실패하고 systemd 가 포기한다. 그러면 nginx 가 내려간 채로 멈춘다.
## 훅을 넣고 같은 방법으로 다시 쟀다
앞선 실험과 같이 강제 갱신을 걸고 밖에서 일련번호를 폴링했다.
| 갱신에서 서빙까지 무엇이 달라졌나 | 훅 없음 | 훅 있음 |
|---|---|---|
| 걸린 시간 | 2305초 = 38분 25초 | 1~2초 |
| reload 를 부른 것 | 사람 | certbot deploy 훅 |
![certbot 이 갱신에 성공한 뒤 deploy 훅이 nginx 를 reload 하는 경로와, 그 훅이 없어 사람이 개입해야 하는 경로가 갈리는 구성.](../../../final/assets/d4a-hook-effect/d4a-hook-effect.svg)
그림은 `certbot 갱신 성공` 에서 `새 인증서 서빙` 까지를 한 줄로 잇는다. 갱신이 끝나면 `deploy 훅` 이 돌고, 그 훅이 `nginx 워커 교체` 로 reload 신호를 보내며, 워커가 새로 뜬 뒤에야 새 인증서가 나간다. 훅이 없던 동안에는 두 번째 단계가 비어 있어 사슬이 이어지지 않았고, 같은 구간이 훅 없음 2305초와 훅 있음 1~2초로 갈린다.
## 1~2초를 재려니 시계가 먼저 걸렸다
훅 실행 시각과 인증서 발급 시각을 그냥 빼면 107초가 나온다. 참값은 1~2초라 약 106초가 어긋난 값이고, 보정을 반대쪽에 걸면 음수가 되는데 훅은 갱신이 끝나야 돌기 때문에 그런 순서는 성립하지 않는다.
원인은 둘이었다. 하나는 Let's Encrypt 가 `notBefore` 에 발급 시각보다 정확히 1시간 앞선 값을 넣기 때문이다. 클라이언트 시계가 조금 느려도 아직 유효하지 않은 인증서로 거부되지 않게 하려는 여유다. 그래서 `notBefore` 를 발급 시각으로 읽으면 1시간이 어긋난다. 1시간을 되돌리고 나서야 남은 106초가 드러났고, 그것이 다른 하나였다. `test-server` 는 시계를 서버에 맞춰 주는 NTP(Network Time Protocol)가 꺼져 있어 106초 빨랐고, dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였다. 두 시계에서 온 값을 그냥 뺀 탓이었다.
보정이 맞는지는 제3의 시계로 확인했다. 새 인증서에는 SCT(Signed Certificate Timestamp, 공개 로그가 인증서 발급을 받아 적고 서명해 돌려준 시각)가 두 개 박혀 있고 그 타임스탬프는 CT 로그가 자기 시계로 서명한 값이라 dev 머신도 `test-server` 도 아니다. SCT 의 `Sep 4 12:27:49.054 GMT` 가 보정한 훅 시각의 정확히 1초 앞에 놓였다.
같은 왜곡이 앞선 실험의 공백에도 걸려 있었고, 보정하기 전에는 2199초로 106초 짧게 적혀 있었다.
## certbot 이 찍은 error 는 실패가 아니었다
certbot 출력에는 `Hook 'deploy-hook' ran with error output` 이 찍혔다. 훅이 실패한 것으로 읽히는 문구인데 인증서는 정상으로 갱신됐고 nginx 도 reload 됐다.
내용을 열어 보면 nginx 가 `types_hash` 를 최적 크기로 만들지 못했다는 경고를 stderr 로 내보낸 것뿐이다. 같은 출력 안에 설정 검사가 통과했다는 `test is successful` 과 reload 신호가 전달됐다는 `signal process started` 가 함께 있다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 이 훅이 실패로 집계된다.
그래서 훅이 실제로 일을 했는지는 로그 문구가 아니라 nginx 프로세스로 확인했다. reload 는 마스터를 유지한 채 워커만 새로 띄우므로 마스터 PID 가 그대로이고 워커 PID 만 바뀌면 reload 가 된 것이다. 같은 호스트에서 `systemctl status` 를 읽으면 cgroup 블록에 그대로 나온다.
```text label="마스터는 9월 3일 그대로이고 워커만 바뀌어 있다"
CGroup: /system.slice/nginx.service
├─ 585 "nginx: master process /usr/bin/nginx"
└─37252 "nginx: worker process"
```
## 사람이 건 reload 는 진행 중이던 요청을 끊지 않았다
여기 실린 수치는 앞선 실험에서 **사람이 손으로 건 reload** 를 잰 것이다. 그때는 훅이 없었고 그것이 앞선 실험의 진단이었다. 새 연결 8856건이 전부 200 이었고, 응답 시간도 reload 직전 p95 205.7ms 에서 직후 204.3ms 로 움직이지 않았다.
진행 중이던 요청 쪽이 더 분명하다. 845KB 짜리 응답을 20k/s 로 느리게 받던 요청 하나가 전송 12초째에 reload 를 맞았는데, 845361바이트를 온전히 받고 끝났고 연결은 한 번뿐이었다. reload 신호를 받은 마스터는 새 워커를 띄우고 옛 워커에게는 들고 있던 요청을 끝내고 물러나라고 하므로, 이 요청은 처음부터 끝까지 옛 워커가 책임졌다.
## 확인하지 않은 것
**훅이 거는 reload 가 무중단인지는 재지 않았다.** 위의 8856건과 845361바이트는 사람이 건 reload 를 잰 값이다. 이 실험이 잰 것은 갱신에서 서빙까지의 공백이 1~2초로 줄었다는 것이고, 워커가 갈린 것은 PID 로 확인했다 — 사람이 걸었을 때 28829, 훅이 걸었을 때 37252 다.
실제 갱신 주기에서 훅이 도는 것은 확인하지 않았다. 강제 갱신으로만 검증했다.
nginx 가 종료될 때 진행 중이던 요청이 어떻게 되는지도 재지 않았다. systemd 유닛은 nginx 에 `KillSignal=SIGQUIT` 과 `KillMode=mixed` 를 쓰고 SIGQUIT 은 nginx 에서 진행 중 요청을 끝내고 종료하라는 뜻이라 reload 와 같은 성질이 걸려 있는데, 종료 쪽은 이번에 걸어 보지 않았다.
<!-- body:end -->
@@ -0,0 +1,182 @@
---
kind: CASE
slug: the-certificate-that-took-38-minutes-to-reach-the-wire
title: 새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4
assets:
- key: renewal-to-serving-gap
file: ../../../final/assets/renewal-to-serving-gap/renewal-to-serving-gap.svg
evidence:
- ../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt
- ../../../final/evidence/raw/d4-certificate-renewal__09-serial-timeline.txt
- ../../../final/evidence/raw/d4-certificate-renewal__12-certbot-state.txt
- ../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt
---
# 새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다
새 인증서가 디스크에 기록된 08:20:27 부터 밖에서 일련번호가 바뀐 08:58:52 까지 2305초, 38분 25초 동안 옛 인증서가 나갔다. 갱신을 nginx 에 알리는 경로 셋이 모두 비어 있었기 때문이고, 38분에 멈춘 것도 사람이 reload 를 쳤기 때문이다. 그동안 certbot 타이머는 매번 SUCCESS 로 끝났다.
## 관계
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
이 실험은 처방을 적어 놓고 검증하지 않았는데, 그 처방을 실제로 넣고 다시 잰 것이 그 실험이다.
- **reload 를 사람이 아니라 deploy 훅이 부르게 한다**
reload 를 부르는 경로가 셋 다 비어 있다는 이 관측이 그 결정의 근거다.
- **갱신 타이머가 실제 갱신에서도 도는가**
여기서는 강제 갱신으로만 확인했고, 만료 30일 전에 타이머가 스스로 갱신하는 경로는 아직 열려 있다.
## 문제
호스트 nginx 가 Let's Encrypt 인증서로 TLS(전송 계층 보안, 연결을 암호화하는 규격)를 끝내고 traefik 으로 넘긴다. certbot 타이머는 하루 두 번 돌고 종료 상태는 매번 SUCCESS 다.
원래 답하려던 물음은 nginx reload 중 진행 중이던 요청이 어떻게 되는가였다. 그것을 재려고 강제 갱신을 걸었는데 밖에서 본 인증서의 일련번호가 바뀌지 않았다. 갱신이 실패한 것인지, 갱신은 됐는데 nginx 가 새 인증서를 읽지 않은 것인지는 이 시점에 갈라져 있지 않았다.
## 결론
갱신은 성공했고 nginx 가 새 인증서를 읽지 않았다.
새 인증서 디스크 기록 : 08:20:27
밖에서 본 일련번호가 바뀐 시각 : 08:58:52
그 사이 공백 : 2305초 = 38분 25초
그 구간에서 옛 인증서로 관측한 횟수 : 428회
certbot-renew.service 의 ExecStartPost : x
renewal-hooks 의 deploy, post, pre : x — 셋 다 비었음
certbot 의 nginx 플러그인 : x
08:58:52 에 reload 를 부른 것 : 자동화가 아니라 사람
그 reload 가 무중단이었는가 : o — 새 연결 8856건 전부 200, 전송 중이던 요청도 전량 수신
reload 를 부르는 경로 셋이 다 비어 있었다. 하나라도 있었으면 갱신과 동시에 반영됐다. 사람이 치지 않았다면 다음 nginx 재시작까지 옛 인증서가 나갔을 것이다.
이 결함은 88일 동안 드러나지 않는다. 타이머는 정상이고 매번 SUCCESS 로 끝나며, 만료 30일 전까지는 certbot 이 갱신 자체를 하지 않아 발현할 기회가 없다.
## 검증 환경
호스트 : test-server, Arch Linux, 12GB, WiFi only
TLS 종단 : 호스트 nginx, Let's Encrypt 인증서, traefik 으로 프록시
인증서 : auth, app1, app2 세 이름이 한 인증서의 SAN(Subject Alternative Name, 한 인증서가 담는 이름 목록)에 있고 와일드카드가 아니다
갱신 도구 : certbot, nginx 플러그인 없음
호스트 sudo : 비밀번호를 요구한다. 갱신과 reload 는 사람이 직접 친다
시계 : test-server 는 NTP 가 꺼져 있어 106초 빨랐다. 아래 시각과 공백은 보정한 값이다
일련번호 폴링 : 5초 간격, 564표본
측정일 : 2026-09-04
## 재현 조건
1. 갱신 전 인증서의 일련번호와 notAfter 를 밖에서 읽어 둔다.
2. 주입 전에 새 연결을 0.2초 간격으로 900회 보내 평시 오류율을 잰다.
3. 845KB 짜리 응답을 20k/s 로 느리게 받아 42초 동안 살아 있는 요청 하나를 만든다.
4. certbot 으로 강제 갱신을 건다.
5. 5초 간격으로 일련번호를 폴링하면서, archive 디렉터리에 새 인증서 파일이 써진 시각과 견준다.
6. certbot-renew.service 의 유닛 파일, renewal-hooks 의 세 디렉터리, certbot 이 찾은 플러그인 목록을 각각 확인한다.
7. nginx 의 마스터와 워커 PID 를 읽어 워커가 언제 뜬 것인지 본다.
8. 두 시계에서 온 값을 빼기 전에 왜곡을 재서 보정한다.
9. 사람이 직접 reload 를 친다. 자동화가 없으므로 여기서 멈춘 것을 푸는 것도 사람이다.
10. reload 전후로 나눠 새 연결의 응답 시간 분포와 비200 건수를 세고,
전송 중이던 요청이 받은 바이트와 연결 수를 본다.
## 본문
<!-- body:start -->
## 대조군을 먼저 잡았다
계획서의 물음은 「nginx reload 중 진행 중이던 요청은 어떻게 되는가」였다. 갱신 중에 비200 이 한 번 나왔다고 해도 평시 오류율을 모르면 그것이 갱신 탓인지 알 수 없으므로, 주입 전에 두 가지를 먼저 쟀다.
| 무엇을 쟀나 | 결과 |
|---|---|
| 새 연결 (0.2초 × 900회 / 180초) | 900 전부 200, 오류 0 · 중앙 98ms · p95 195ms |
| 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · 42.3초 완주 |
두 번째를 따로 잰 까닭은 첫 폴링이 「새 연결을 받아주는가」만 재기 때문이다. 0.2초 폴링은 TLS 핸드셰이크가 900/900 이라 매 요청이 새 연결이고, 계획서가 물은 「진행 중이던 요청」은 reload 순간에 실제로 전송 중인 요청이 있어야 재진다. 그래서 845KB 짜리 번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 두었다.
강제 갱신은 되돌릴 수 없고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 한 장 깎는다. 그래서 이 실험 전체에서 강제 갱신을 한 번만 쓰기로 정했고, 대조군 둘이 그 한 번보다 앞에 왔다. 이 호스트는 sudo 가 비밀번호를 요구해서 강제 갱신도 reload 도 사람이 직접 쳐야 했다. 비대화식 sudo 는 반드시 실패해서, 강제 갱신은 처음에 미측정으로 남아 있었다. 명령 한 줄을 헛되이 쓰지 않는 것이 이 실험 설계의 일부였다.
## 강제 갱신을 걸었는데 일련번호가 바뀌지 않았다
갱신을 걸고 5초 간격으로 일련번호를 읽었더니 564표본 내내 옛 값이 나왔다. 디스크에는 새 인증서가 있었다.
```text label="디스크와 네트워크가 서로 다른 인증서를 말한다"
디스크 cert2.pem 2026-09-04 17:22:13 KST 기록됨
네트워크 일련번호 564표본 내내 옛 것. 08:58:52 에야 바뀜
```
그래서 「갱신이 실패했다」가 아니라 「갱신은 됐는데 nginx 가 집지 않았다」로 갈렸다.
| 무엇이 언제였나 | 시각 (실제 UTC) |
|---|---|
| 새 인증서 디스크 기록 | `08:20:27` |
| 실제 서빙 시작 | `08:58:52` |
| 공백 | 2305초 = 38분 25초, 그 사이 428회 관측 |
이 2305초는 시계를 보정한 값이고, 처음 적은 값은 2199초 곧 36분 39초였다. 디스크 기록 시각은 archive 디렉터리의 mtime 이라 test-server 시계이고 일련번호를 관측한 쪽은 dev 머신 시계인데, 그 둘을 그대로 뺐기 때문이다. test-server 는 NTP 가 꺼져 있어 106초 빨랐고 dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였다. 이 차이는 훅을 넣고 1~2초를 재려던 다음 실험에서 드러났고, 거기서 2199초를 2305초로 고치면서 관련 문서를 전부 정정했다.
38분에서 멈춘 것도 이 결함의 성질이 아니라 우연이다. `08:58:52` 에 reload 를 시킨 것은 자동화가 아니라 사람이었고, 아무도 치지 않았다면 다음 nginx 재시작까지 옛 인증서가 계속 나갔다.
## reload 를 부를 수 있는 경로가 셋인데 셋 다 비어 있었다
certbot 이 갱신에 성공한 뒤 nginx 에 그것을 알리는 방법은 이 호스트에서 셋이었다.
| 어디서 reload 를 부를 수 있나 | 거기에 무엇이 있었나 |
|---|---|
| certbot-renew.service 의 `ExecStartPost` | 없음. 배포판이 넣어 준 유닛에 `ExecStart` 하나뿐이다 |
| `/etc/letsencrypt/renewal-hooks/` 의 `deploy` · `post` · `pre` | 셋 다 비었음 |
| certbot 의 nginx 플러그인 | 없음 — `dns-cloudflare, manual, null, standalone, webroot` |
nginx 가 읽는 인증서는 `fullchain.pem` 이고, 그 파일을 기동할 때 한 번 읽어 메모리에 들고 있다. certbot 은 설정이 가리키는 경로를 고치는 대신 `live/` 심볼릭 링크가 새 파일을 가리키게 갈아끼우므로, 경로는 그대로이고 가리키는 대상만 바뀐다. nginx 설정에는 손댈 것이 없고 바로 그 때문에 설정만 읽으면 멀쩡해 보인다. 고칠 것은 설정이 아니라 reload 를 부르는 경로다.
![certbot 이 archive 에 새 인증서를 쓰고 live 링크를 옮기지만, nginx 워커가 교체되지 않아 옛 인증서를 계속 서빙하는 구성.](../../../final/assets/renewal-to-serving-gap/renewal-to-serving-gap.svg)
그림에서 `nginx 워커` 로 들어오는 화살표는 둘이다. `live/fullchain.pem` 에서 오는 쪽에는 「reload 필요」가 붙어 있고, 그 reload 를 부르는 신호는 `renewal-hooks/deploy` 에서 온다. 이 실험대에서는 그 디렉터리가 비어 있어 신호를 보낼 것이 없었고, 그래서 새 인증서가 기록된 뒤에도 워커는 옛 인증서를 들고 있었다.
## reload 가 있었는지는 워커 PID 로 가른다
nginx 의 reload 는 마스터를 유지한 채 워커만 새로 띄운다. 그래서 마스터 PID(Process ID, 프로세스 번호)가 그대로이고 워커 PID 만 바뀌었으면 reload 가 된 것이고, 둘 다 그대로이면 없었던 것이다.
```text label="갱신 직후 nginx 의 마스터와 워커"
585 1 80529 Thu Sep 3 19:00:39 nginx: master process
586 585 80529 Thu Sep 3 19:00:39 nginx: worker process
```
워커 586 은 마스터 585 가 기동한 직후의 첫 fork 이고 기동 시각도 경과 시간도 마스터와 같다. 22.4시간 동안 워커가 한 번도 교체되지 않았으므로 reload 도 한 번도 없었다. 로그에 무엇이 적혔는지를 보지 않고 지금 떠 있는 프로세스만으로 갈린다.
## 88일 동안 드러나지 않는다
certbot 타이머는 정상이고 실행은 매번 SUCCESS 로 끝난다. certbot 은 만료 30일 전이 되어야 갱신을 시도하므로, 그때까지는 갱신 자체가 없어서 「갱신해도 반영되지 않는다」는 결함이 나타날 기회가 없다. 이 호스트에서는 그 구간이 88일이다.
발현하는 날의 증상은 인증서 만료이고, 그날에도 타이머 로그에는 SUCCESS 라고 적혀 있다. 강제 갱신을 걸어 일련번호를 밖에서 폴링하지 않았다면 이 실험대에서도 그날까지 보이지 않았다.
## reload 자체는 무중단이었다
원래 물음이었던 「reload 중 진행 중이던 요청은 어떻게 되는가」는 답이 나왔다. 다만 잰 것은 자동화가 부른 reload 가 아니라 `08:58:52` 에 사람이 친 reload 다.
```text label="사람이 친 reload 전후"
새 연결 8856건 전부 200 · p95 205.7 → 204.3ms
진행 중 요청 전송 12초째에 reload · 845361바이트 전량 · 연결수 1
```
845KB 짜리 응답을 20k/s 로 느리게 받아 42초 동안 살려 둔 요청이 있었고, 그 전송 한가운데에서 reload 가 걸렸다. 받은 바이트가 전량이고 연결 수가 1이므로 중간에 끊겨 다시 연결한 것이 아니다. 옛 워커가 그 요청을 끝까지 책임졌다.
새 연결 쪽도 같다. p95 가 205.7 밀리초에서 204.3 밀리초로 사실상 그대로이고 비200 은 한 건도 없었다.
## 확인하지 않은 것
실제 만료가 임박한 상태를 만들지 않았다. 이 결함이 만료로 드러나는 경로는 재지 않았다.
무중단을 확인한 reload 는 사람이 건 것이다. certbot 의 deploy 훅이 부르는 reload 에서도 같은지는 여기서 재지 않았고, 훅을 넣고 다시 돌린 실험이 그것을 이어받았다.
<!-- body:end -->
@@ -0,0 +1,134 @@
---
kind: CASE
slug: the-upgrade-that-would-not-roll-back
title: 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d1-d2
assets:
- key: d2-upgrade-direction
file: ../../../final/assets/d2-upgrade-direction/d2-upgrade-direction.svg
evidence:
- ../../../final/evidence/raw/d2-version-upgrade__02-rollback-attempt.txt
- ../../../final/evidence/raw/d2-version-upgrade__03-roll-forward.txt
---
# 되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다
되돌리기가 옛 버전 파드의 기동 단계에서 막혔다. 새 버전이 스키마에 남긴 체크섬을 Liquibase 가 거부했기 때문이다. 그동안 외부 서비스는 살아 있었는데, 롤링 업데이트가 첫 파드에서 멈춰 남은 파드가 계속 응답했다. 되돌릴 수 있는지는 databasechangelog 의 행 수가 가른다.
## 관계
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
되돌릴 수 있는지도 계획서에 적힌 문장이 아니라 databasechangelog 의 행 수로 갈랐다.
- **reload 를 사람이 아니라 deploy 훅이 부르게 한다**
운영 절차의 결과를 사람의 기억이 아니라 실행 가능한 검사에 맡긴다는 물음이 여기에도 같이 걸려 있다.
## 문제
버전을 올리는 절차에는 되돌리기가 따라붙는다. 이 실험대에는 그 롤백 계획이 적혀 있지 않았다.
되돌리기를 실제로 걸어 본 적도 없었다. 이미지 태그를 옛 버전으로 되돌리면 파드가 뜨는지, 되돌리는 동안 밖에서 보는 서비스가 어떻게 되는지는 재지 않은 상태였다.
## 결론
되돌리기는 기동 단계에서 막혔고 외부 서비스는 그동안 살아 있었다.
정방향 26.7.0 에서 26.7.3 : 무중단. 요청 87회 전부 200
되돌리기 : 옛 버전 파드가 기동 단계에서 막혔다
막은 것 : Liquibase 의 체크섬 검증
그때 외부 서비스 : 살아 있었다. StatefulSet 롤링 업데이트가 첫 파드에서 멈췄다
되돌릴 수 있는지 가르는 기준 : databasechangelog 의 행 수가 업그레이드 전후로 같은가
스키마 변경 없는 되돌리기 26.7.3 에서 26.7.0 : 성공. 전환 순간 000 이 1회
「롤백 불가」는 조건부다. 새 버전이 changeset 을 추가하지 않았으면 옛 버전으로 되돌아간다. 추가했으면 옛 버전이 그 행의 체크섬을 거부하고 기동에서 멈춘다.
롤백 계획을 적어 두지 않았더라도 이 사고는 전면화되지 않았다. 롤링 업데이트가 첫 파드에서 멈추면서 나머지 파드를 그대로 두었기 때문이다.
## 검증 환경
Keycloak : 26.7.0 에서 26.7.3 으로 올리고 다시 되돌렸다
워크로드 : StatefulSet 2파드
스키마 관리 : Liquibase. 적용한 changeset 을 databasechangelog 테이블에 기록한다
데이터베이스 : PostgreSQL
가용성 측정 : 업데이트가 도는 동안 밖에서 요청을 반복해 상태 코드를 셌다
측정일 : 2026-09-04. 되돌린 파드가 남긴 로그 줄의 날짜다
## 재현 조건
1. 업그레이드 전에 데이터베이스를 백업한다.
2. databasechangelog 의 행 수를 세어 둔다.
3. 이미지 태그를 새 패치 버전으로 바꿔 StatefulSet 롤링 업데이트를 건다.
4. 업데이트가 도는 동안 밖에서 요청을 반복해 200 과 비200 을 센다.
5. databasechangelog 의 행 수를 다시 세어 업그레이드 전과 같은지 본다.
6. 이미지 태그를 옛 버전으로 되돌리고, 첫 파드가 기동하는지와 그 파드의 로그에 무엇이 찍히는지 본다.
7. 되돌리는 동안에도 밖에서 요청을 반복해 남은 파드가 응답하는지 확인한다.
## 본문
<!-- body:start -->
## 올리는 방향은 아무것도 끊지 않았다
Keycloak 두 파드를 StatefulSet 으로 띄워 두고 이미지 태그를 26.7.0 에서 26.7.3 으로 바꿨다. 업데이트가 도는 동안 밖에서 요청을 계속 보냈고 87회가 전부 200 이었다.
이 방향에서는 파드가 새 이미지로 다시 뜨는 것 말고 걸리는 단계가 없었다. 되돌리기를 시도한 것은 이 상태에서다.
## 되돌리기는 기동 단계에서 막혔다
이미지 태그를 옛 버전으로 되돌리자 새로 뜬 파드가 기동하지 못했다. 로그에 찍힌 것은 애플리케이션 오류가 아니라 스키마 검증이었다.
```text label="옛 버전 파드가 기동하면서 남긴 것"
liquibase ValidationFailedException: 1 changesets check sum
```
Liquibase 는 스키마 변경을 changeset 단위로 적용하고 적용한 것을 `databasechangelog` 테이블에 한 행씩 기록하는데, 각 행에는 그 changeset 내용의 체크섬이 함께 들어간다. 기동할 때 자기가 들고 있는 changeset 파일의 체크섬과 테이블에 적힌 체크섬을 대조하고, 다르면 거기서 멈춘다. 새 버전이 남긴 행을 옛 버전이 자기 파일과 대조했더니 맞지 않았고, 그래서 데이터베이스에 손을 대기 전에 기동을 포기했다.
막은 것은 애플리케이션 코드도 이미지도 아니라 데이터베이스에 이미 적힌 한 행이다.
되돌리기를 걸고 20초 간격으로 여덟 번 파드 상태를 읽었는데 그중 다섯 번은 `Running` 이었고 `Error` 가 두 번, `CrashLoopBackOff` 가 한 번이었다. 여덟 번 모두 준비된 컨테이너는 0/1 이었다. 상태 칸만 보면 `Running` 인 때도 있었으므로, 기동하지 못했다는 것은 그 0/1 과 파드 로그를 보고 판단했다.
## 되돌리기가 막힌 동안에도 서비스는 살아 있었다
기동하지 못한 파드는 첫 번째 파드였다. StatefulSet 의 롤링 업데이트는 파드를 하나씩 교체하고 앞의 파드가 준비 상태가 되어야 다음으로 넘어가므로, 첫 파드가 기동하지 못한 시점에 업데이트가 거기서 멈추고 나머지 파드는 옛 이미지 그대로 남았다. 밖에서는 남은 파드가 계속 응답해 외부 진입점이 `HTTP 200` 이었고, Service 의 준비된 엔드포인트에는 주소가 하나 남아 있었다.
![앞으로 가는 경로는 무중단이고 뒤로 가는 경로는 Liquibase 검증에서 막히는 구성. 롤링 업데이트가 그 사고를 절반에서 멈춘다.](../../../final/assets/d2-upgrade-direction/d2-upgrade-direction.svg)
그림에서 `옛 버전 롤백` 은 `Liquibase 검증` 으로만 이어지고, 거기서 두 갈래가 나간다. 한쪽은 `databasechangelog 행 수` 이고 판단 기준이라는 이름이 붙어 있으며, 다른 쪽은 `StatefulSet 롤링 업데이트` 이고 중단 지점이라는 이름이 붙어 있다. 그 중단 지점에서 `외부 서비스` 로 가는 화살표에는 잔여 파드 응답이 붙는다. 되돌리기를 막은 단계와 사고를 절반에서 멈춘 단계가 같은 검증에서 갈라져 나온다.
롤백 계획을 적어 두지 않은 상태에서 되돌리기를 시도했는데도 전면 중단으로 가지 않았다. 그것은 계획이 좋아서가 아니라 StatefulSet 의 롤링 업데이트가 실패한 파드에서 교체를 멈췄기 때문이다.
## 롤백 불가는 조건부였다
이 실험을 처음 적을 때는 「롤백은 안 된다」고 단정했고, 후속 실험에서 정정했다. 실제로 막은 것은 버전 번호가 아니라 새 버전이 스키마에 행을 더했다는 사실이라, 새 버전이 changeset 을 하나도 더하지 않았으면 옛 버전은 대조에서 걸릴 것이 없다.
그래서 판단 기준이 한 줄로 정해진다.
```sql label="되돌릴 수 있는지 가르는 한 줄"
select count(*) from databasechangelog
```
업그레이드 전후로 이 수가 같으면 되돌아가고, 늘었으면 옛 버전이 기동에서 멈춘다. 이 기준으로 다시 걸어 본 26.7.3 에서 26.7.0 으로의 되돌리기는 성공했고, 전환 순간에 `000` 이 한 번 나왔다. 그 `000` 은 서버가 오류를 돌려준 것이 아니라 `--max-time 3` 을 넘긴 것이다. 끊긴 것과 느린 것은 다르고, 그 구별은 상태 코드가 아니라 타임아웃 값을 알고 있어야 선다.
이 수는 실제로 세었다. 되돌리기가 막힌 뒤 다시 앞으로 올려 놓고 확인했더니 마이그레이션 210 과 세션 4 가 업그레이드 전에 세어 둔 값 그대로였다.
업그레이드를 걸기 전에 이 수를 세어 두면 되돌릴 수 있는지를 사고가 나기 전에 안다. 계획서에 「롤백 가능」이라고 적어 두는 것과 이 쿼리를 전후로 돌려 보는 것은 다른 일이다.
다만 이 기준의 두 갈래를 같은 만큼 확인하지는 않았다. 「같으면 되돌아간다」는 26.7.3 에서 26.7.0 으로 실제로 걸어 본 결과이고, 「늘었으면 멈춘다」는 옛 버전에서 본 기동 실패를 근거로 한 추론이다. 26.7.x 사이에는 스키마 변경이 없어서 행이 늘어난 뒤 태그를 되돌리는 경우는 이 실험대에서 만들지 못했다.
## 확인하지 않은 것
스키마가 크게 바뀌는 메이저 업그레이드에서는 재지 않았다. 두 패치 버전 사이만 확인했다.
되돌리기가 막혔을 때 남은 파드가 얼마나 오래 버티는지도 재지 않았다. 관측한 것은 첫 파드가 기동하지 못하는 동안 밖에서 응답이 계속 왔다는 것까지이고, 그 상태를 길게 두었을 때 무엇이 먼저 깨지는지는 걸어 보지 않았다.
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: PROJECT_DECISION
slug: put-the-reload-in-a-deploy-hook
title: reload 를 사람이 아니라 deploy 훅이 부르게 한다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
decisionStatus: ADOPTED
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
evidence:
- ../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
- ../../../final/evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt
- ../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt
---
# reload 를 사람이 아니라 deploy 훅이 부르게 한다
인증서 갱신 뒤의 nginx reload 를 사람이 아니라 certbot deploy 훅이 부르게 정했다. 사람이 치던 절차로 두었을 때 갱신에서 서빙까지 2305초가 비었고, 훅을 넣은 뒤에는 1~2초였다.
## 근거
- **새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다**
사람이 치는 절차로 뒀을 때의 공백 2305초를 잰 기록이다. 이 결정이 고르지 않은 쪽을 실제로 재 본 값이 거기 있다.
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
이 결정대로 훅을 설치하고 갱신에서 서빙까지를 다시 잰 기록이다.
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
훅이 실제로 reload 를 걸었는지를 무엇으로 가릴지, 이 결정이 따르는 기준을 적은 기록이다.
- **갱신 타이머가 실제 갱신에서도 도는가**
이 결정을 강제 갱신에서만 확인했다는 것을 열린 질문으로 남긴 기록이다.
## 결정문
인증서를 갱신한 뒤의 nginx reload 는 /etc/letsencrypt/renewal-hooks/deploy/ 에 설치한 훅 파일이 부른다. 사람이 손으로 reload 를 치는 절차로 두지 않는다.
훅의 내용은 두 줄이고 nginx -t && nginx -s reload 다.
## 판단 이유
nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있는데, certbot 은 인증서 파일의 경로가 아니라 live/ 심볼릭 링크가 가리키는 대상을 갈아끼운다. 그래서 갱신이 끝나도 nginx 설정은 멀쩡해 보이고 옛 인증서가 계속 나간다. 고칠 것은 설정이 아니라 reload 를 부르는 경로인데, 이 실험대에서는 그 경로가 셋 다 비어 있었다. certbot-renew.service 에 ExecStartPost 가 없었고 certbot 에 nginx 플러그인도 설치돼 있지 않았다. renewal-hooks 아래 pre 와 deploy 와 post 세 디렉터리도 모두 비어 있었다.
사람이 치는 절차로 두는 대안은 반사실이 아니라 D-4 에서 실제로 관측했다. 새 인증서가 디스크에 기록되고 서빙이 바뀌기까지 2305초, 38분 25초가 비었고 그 reload 를 부른 것은 자동화가 아니라 사람이었다. 그 38분은 우연히 짧았을 뿐이고, 아무도 치지 않았다면 다음 nginx 재시작까지 옛 인증서가 나갔을 것이다.
훅을 놓을 디렉터리는 셋 중 하나였다. pre 는 갱신을 시도하기 전에 돌아서 새 인증서가 나오기 전이고, post 는 갱신 여부와 무관하게 매번 돈다. 타이머가 하루 두 번 도니 post 에 넣으면 갱신이 없는 날에도 nginx 를 하루 두 번 reload 하게 된다. deploy 는 certbot 이 RENEWED_LINEAGE 를 넘겨줄 때, 즉 실제로 갱신했을 때만 돈다.
두 줄 중 앞의 nginx -t 도 같은 종류의 안전장치다. 설정이 깨진 상태에서 nginx -s reload 를 보내면 마스터가 새 워커를 못 띄우는데, -t 로 먼저 검사해 통과할 때만 reload 하면 실패했을 때 옛 워커가 그대로 서비스를 계속한다. 인증서는 안 바뀌지만 서비스는 죽지 않는다. 이 순서 하나가 「인증서가 안 바뀐다」와 「사이트가 내려간다」를 가른다.
훅을 설치하고 강제 갱신을 다시 돌리자 갱신에서 서빙까지가 1~2초로 줄었다. 워커 PID(process ID, 프로세스 번호)는 사람이 걸었을 때의 28829 에서 37252 로 바뀌었다.
## 영향
- 갱신에서 서빙까지는 훅이 없던 D-4 에서 2305초, 훅을 넣은 D-4a 에서 1~2초였다. 두 값은 회차마다 한 번씩 잰 것이라 같은 조건에서 되풀이해 잰 값이 아니다.
- 이 훅은 인증서를 세우는 구축 절차에 들어간다. 원 가이드가 「이 훅은 구축 절차에 들어가야 한다. 사후에 붙이는 것이 아니다」라고 적었다. D-4 가 잰 2305초의 공백은 훅이 없어서 생긴 것이라, 그 훅은 인증서를 처음 세울 때 같이 놓였어야 했다.
- 되돌리기는 훅 파일을 지우는 한 줄이지만 지우지 않는다. 훅은 결함을 고치는 파일이라 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다.
- certbot 은 훅이 성공해도 Hook 'deploy-hook' ran with error output 이라고 찍는다. 훅의 실제 출력은 test is successful 과 signal process started 이고, 그 문구가 붙은 까닭은 nginx 의 types_hash 경고가 stderr 로 나갔기 때문이다. certbot 은 stderr 에 무엇이든 있으면 이 문구를 붙이고, 그 경고 자체는 types_hash_max_size 기본값에서 오는 것이라 갱신과 무관하다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
- 확인은 --force-renewal 로 한 강제 갱신에서만 했다. certbot-renew.timer 가 스스로 갱신하는 경로에서도 같은 훅이 도는지는 아직 재지 않았다.
- 훅이 거는 reload 가 진행 중이던 요청을 끊지 않는지는 재지 않았다. 무중단을 확인한 측정은 D-4 에서 사람이 손으로 건 reload 를 잰 값이다.
@@ -0,0 +1,103 @@
---
kind: QUESTION
slug: does-the-renewal-timer-actually-renew
title: 갱신 타이머가 실제 갱신에서도 도는가
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
questionStatus: OPEN
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-재보지-않은-것
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
evidence:
- ../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt
- ../../../final/evidence/raw/d4-certificate-renewal__12-certbot-state.txt
- ../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
- ../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt
---
# 갱신 타이머가 실제 갱신에서도 도는가
deploy 훅은 강제 갱신으로만 검증했고, 타이머가 스스로 갱신하는 날도 그 훅이 도는지는 재지 않았다.
만료 30일 전에야 조건이 성립해 약 59일 뒤에나 시험할 수 있다. 그때까지 타이머는 매번 SUCCESS 를 찍는다.
## 관계
- **새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다**
이 질문이 나온 결함이다. reload 를 부르는 경로가 셋 다 비어 있었다.
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
그 훅을 강제 갱신으로만 시험했고, 타이머 경로는 남겨 두었다.
## 사실
- certbot-renew.timer 는 하루 두 번 도는 일정으로 잡혀 있다.
RandomizedDelaySec : 12h
Persistent : true
- 2026-09-04 에 그 타이머가 두 번 돌았고 둘 다 status=0/SUCCESS 로 끝났다.
- 같은 날 서빙 인증서는 만료까지 88일 남아 있었다. 두 번의 SUCCESS 는 갱신을 하지 않은 채 끝난 것이다.
- certbot 의 인증서 상태 출력이 남은 일수를 VALID: 89 days 로 찍었다. 89일은 만료까지 남은
일수다. 다만 그 출력은 D-4 의 강제 갱신이 만든 인증서를 본 것이고, D-4a 가 다시 받은
인증서에 대해서는 같은 출력이 증거에 없다. 두 장은 만료 시각이 다르다.
certbot 은 30일 남았을 때 갱신하므로 갱신 조건은 약 59일 뒤에 성립한다.
- D-4 에서 새 인증서가 디스크에 기록된 시각과 실제 서빙이 바뀐 시각 사이가 2305초, 38분 25초 비었다.
그 사이 428회 관측했다.
- 그때 reload 를 건 것은 사람이었다. certbot-renew.service 에 ExecStartPost 가 없었고
renewal-hooks 의 deploy · post · pre 세 디렉터리가 다 비어 있었으며 certbot 의 nginx 플러그인도 없었다.
- D-4a 에서 deploy 훅 하나를 넣고 강제 갱신하자 갱신에서 서빙까지가 1~2초로 줄었다.
- 누가 reload 를 걸었는지는 워커 프로세스 번호(PID)로 갈린다. 사람이 걸었을 때 28829,
훅이 걸었을 때 37252 였고 마스터 585 는 그대로였다.
- 훅이 돌 때 certbot 은 Hook 'deploy-hook' ran with error output 을 찍었다. 실패는 아니었고
nginx 의 types_hash 경고가 stderr 로 나간 것이며 내용은 test is successful · signal process started 였다.
## 가정
- 타이머가 스스로 갱신하는 경로도 같은 certbot renew 를 부르고 같은 deploy/ 훅을 실행한다.
이 실험대의 기록이 그렇게 판단했고, 그 판단대로라면 남은 미지수는 타이머가 뜨는가 하나이며
그것은 D-4 에서 확인됐다. 훅 실행까지 같다는 부분은 실행으로 확인하지 않았다.
- 갱신일까지 아무도 이 호스트의 타이머 유닛과 훅 파일을 건드리지 않는다.
## 미지수
- 타이머가 실제 갱신을 수행하는 날에도 deploy/ 훅이 도는가.
이 실험대가 훅을 확인한 경로는 --force-renewal 하나뿐이다.
- --dry-run 에서 Running deploy-hook command 줄이 나오는가.
강제 갱신으로 바로 검증하는 바람에 dry-run 경로 자체를 거치지 않았다.
- 훅이 실제로 실패하면 certbot 이 무엇을 찍는가. 성공한 훅의 출력만 봤다.
- 훅이 /tmp 에 로그를 남기도록 만들면 타이머가 돌렸을 때 그 파일을 밖에서 찾을 수 있는가.
certbot-renew.service 는 PrivateTmp=true 이고, 이 실험은 훅에 로그를 넣지 않았다.
## 제약
- 만료 30일 전에야 갱신 조건이 성립하므로 실제 갱신은 약 59일 뒤다. 다만 기다리는 것
말고도 이 실험대가 적어 둔 길이 하나 있다 — certbot renew --dry-run 은 인증서를 발급하지
않고 발급 한도도 깎지 않으면서 훅이 호출되는지까지는 보여 준다.
- 강제 갱신으로는 이 질문에 답할 수 없다. 그 경로는 D-4a 에서 이미 검증했고, 지금 묻는 것은
타이머가 스스로 도는 경로다.
- deploy 훅 파일은 지우지 않고 남긴다. 지우면 D-4 의 상태로 돌아간다.
- 발급 한도가 있다. 강제 갱신을 쓸 때는 이번 주에 몇 장 발급했는지 센다.
## 선택지
갱신일이 와야 끝까지 시험할 수 있지만 그 전에 고를 수 있는 것이 하나 있다.
certbot renew --dry-run 을 먼저 돌려 훅이 호출되는지만 본다 : 발급도 한도 소모도 없다.
다만 실제 갱신에서 훅이 무엇을 받는지까지는 답하지 않는다. 이 실험대는 이 줄을 미검증으로
표시했고 돌리지 않았다.
약 59일을 기다렸다가 실제 갱신을 본다 : 이 질문에 온전히 답하는 유일한 경로다.
## 다음 검증
약 59일 뒤, 만료 30일 전 조건이 성립해 타이머가 갱신을 수행한 날에 두 가지를 읽는다.
1. nginx 워커의 lstart 를 읽어 갱신 시각 근처인지 본다. 마스터는 그대로이고 워커만 새것이어야 한다.
2. 서빙 인증서의 serial 과 notAfter 를 읽어 notAfter 가 밀렸는지 본다.
판정은 이 둘로 한다. certbot 이 찍는 문구로는 갈리지 않는다 — D-4a 에서 ran with error output 은
실패가 아니었다.
닫는 조건 : 워커 PID 가 바뀌고 서빙 일련번호가 새 인증서와 같으면 닫는다. 그렇지 않으면 훅이 강제
갱신에서만 도는 것이므로 타이머 유닛 쪽에 훅을 다시 건다.
@@ -0,0 +1,84 @@
---
kind: REFERENCE
slug: judge-a-reload-by-the-worker-pid-not-the-log
title: 적용됐는지는 로그 문구가 아니라 상태로 판정한다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4
- final/document.md#선택이-코드와-흐름에-반영되는-방식-d4a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
evidence:
- ../../../final/evidence/raw/d4-certificate-renewal__07-renewal-hook-missing.txt
- ../../../final/evidence/raw/d4-certificate-renewal__09-serial-timeline.txt
- ../../../final/evidence/raw/d4-certificate-renewal__13-verdict.txt
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
- ../../../final/evidence/raw/d4a-deploy-hook__02-certbot-with-hook.txt
- ../../../final/evidence/raw/d4a-deploy-hook__03-after-state.txt
---
# 적용됐는지는 로그 문구가 아니라 상태로 판정한다
설정이나 인증서를 다시 읽었는지는 로그 문구가 아니라 워커 PID 로 판정한다. D-4 에서 certbot 타이머는 매번 SUCCESS 였는데 밖에서 본 인증서는 2305초 동안 옛 것이었고, D-4a 에서는 훅 로그가 error 를 찍었는데 상태는 바뀌어 있었다.
## 관계
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
훅이 부른 reload 뒤에 워커 번호가 37252 로 바뀐 것을 확인한 실험이다. 로그가 error 를 찍는데도 상태는 바뀌어 있던 쪽이 여기 있다.
- **되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다**
같은 주제의 다른 운영 절차다. 업그레이드를 되돌릴 수 있는지도 로그가 아니라 databasechangelog 의 행 수가 가른다.
## 목적
운영 절차는 성공이라고 적혀 있는데 실제로는 아무것도 바뀌지 않은 상태를 그대로 지나치지 않게 한다. 반대쪽도 같이 막는다 — 로그에 error 가 있다고 실패로 처리하면 성공한 절차를 되돌리게 된다.
D-4 에서 인증서 갱신은 성공했다. certbot-renew.timer 는 그날 두 번 돌았고 서비스는 두 번 다 status=0/SUCCESS 로 끝났으며, 새 인증서도 디스크에 기록됐다. 그런데 밖에서 5초마다 본 일련번호는 564표본 내내 옛 것이었고, 디스크에 기록된 때로부터 2305초, 38분 25초가 지나서야 바뀌었다.
nginx 쪽에서도 틀린 것을 찾을 수 없었다. 설정 파일은 그대로 쓸 수 있는 상태였고 오류도 없었는데, 워커는 마스터 585 가 기동 직후에 만든 첫 fork 인 586 이었고 마스터와 워커의 etimes 가 둘 다 80529초, 22.4시간이었다. 그동안 reload 가 한 번도 일어나지 않았다.
## 규칙
### 1. reload 가 됐는지는 마스터 PID 와 워커 PID 를 함께 읽어 판정한다
마스터 PID(프로세스 번호)는 유지되고 워커 PID 만 바뀌면 reload 된 것이다. D-4 와 D-4a 를 지나는 동안 마스터는 585 그대로였고 워커만 586 에서 28829 로, 다시 37252 로 바뀌었다. ps 로 두 줄을 함께 읽으면 lstart 와 etimes 가 같은 줄에 나오므로 워커가 언제 만들어졌는지까지 한 번에 확인할 수 있다.
### 2. 성공 로그는 절차가 오류 없이 끝났다는 것까지만 말한다
status=0/SUCCESS 는 certbot 이 오류 없이 종료했다는 뜻이고, 서빙되는 인증서가 바뀌었는지는 말하지 않는다. D-4 에서 이 결함은 88일 동안 드러나지 않는다. 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없기 때문이고, 발현하는 날의 증상은 인증서 만료이며 그날에도 로그에는 SUCCESS 라고 적혀 있을 것이다.
### 3. error 가 찍혔다고 실패로 판정하지 않는다
D-4a 에서 certbot 은 Hook 'deploy-hook' ran with error output 이라고 찍었다. 실패가 아니라 nginx 의 types_hash 경고가 stderr 로 나간 것이고, 같은 출력의 내용은 test is successful 과 signal process started 다. 그 실행 뒤 워커는 37252 로 바뀌어 있었으니 reload 는 실제로 됐다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다. 반대 방향은 재지 않았다. 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지는 이 실험대가 보지 못했고, 여기 실린 문구는 성공한 훅에서 나왔다.
### 4. 상태는 절차 밖에서 확인한다
디스크에 새 파일이 쓰인 것과 그 파일이 서빙되는 것은 다른 시각에 일어났다. 그 두 시각 사이 428회 동안 밖에서 본 일련번호는 옛 것이었고, 그동안 certbot 의 출력도 nginx 의 설정 파일도 이상을 말하지 않았다. 확인할 값은 클라이언트가 실제로 받는 인증서의 일련번호다.
### 5. 다른 절차에서는 무엇이 상태인지 먼저 정한다
이 판정법을 확인한 데몬은 nginx 하나다. 다시 읽은 것이 어디에 남는지를 절차마다 먼저 찾고, 그 값을 절차 밖에서 확인한다.
## 적용 조건
- 설정이나 인증서를 다시 읽게 하는 절차를 확인할 때. reload · rotate · reconcile 이 여기 해당한다
- 타이머나 훅이 그 절차를 부르고 사람은 로그만 보는 구성
- 로그 문구로 감시 규칙을 만들 때. error 를 찾는 규칙과 SUCCESS 를 세는 규칙 둘 다
- 절차를 고친 뒤 효과를 잴 때. 훅을 넣은 D-4a 도 워커 번호로 갈렸다
## 예외
- 절차가 프로세스를 완전히 교체하면 PID 비교가 판정이 되지 않는다. 그때는 적재한 값 자체를 확인한다
- 워커 PID 로 판정하는 것은 nginx 의 마스터-워커 모델에서만 확인했다. 다른 데몬에서 같은 비교가 성립하는지는 이 실험대가 보지 않았다
- reload 가 무중단인지는 PID 로 알 수 없다. 사람이 건 reload 는 새 연결 8856건이 전부 200 이었지만, 훅이 거는 reload 를 같은 방식으로 재지는 않았다
## 예시
- 마스터 585 와 워커 586 의 lstart 가 같고 etimes 도 80529초로 같았다. 22.4시간 동안 reload 가 없었다
- 사람이 nginx -s reload 를 친 뒤 마스터는 585 그대로였고 워커는 28829 였다
- 훅이 부른 reload 뒤 워커는 37252 였다. 마스터는 585 에서 바뀌지 않았다
- cgroup.procs 로 읽어도 같은 두 번호 585 와 37252 가 나왔다. 같은 사실을 다른 도구로 다시 본 것이다
- 새 인증서가 디스크에 기록된 때와 실제 서빙이 시작된 때의 공백은 2305초 = 38분 25초였다
- certbot-renew.service 는 그날 두 번 돌았고 두 번 다 status=0/SUCCESS 였다
- 훅을 넣은 뒤 certbot 출력은 Hook 'deploy-hook' ran with error output 인데 내용은 test is successful 과 signal process started 였다
@@ -0,0 +1,745 @@
---
id: 7dc48b91-e31b-455c-9a9d-c766f95ff491
kind: SETUP
slug: reproduce-d1-backup-restore
title: 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7dc48b91-e31b-455c-9a9d-c766f95ff491/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-1
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다
덤프를 뜨고 검증 넷을 통과한 뒤 스키마를 통째로 지우고 같은 명령으로 복구를 대조하는 절차다. 되돌리는 수단이 방금 뜬 파일 하나뿐이라 검증이 파괴보다 먼저 온다.
## 관계
- **되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다**
이 절차가 남긴 덤프 위에 서는 판올림 실험의 결론이다. 스키마가 움직인 뒤에는 덤프만이 되돌리는 수단이 된다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
`-i` 를 빠뜨린 복구가 그 아홉 건과 같은 모양으로 끝난다. 왜 따로 확인해야 하는지를 그쪽이 적는다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
데이터베이스가 통째로 비었는데 정문이 `200` 인 상태를 지표가 어떻게 놓치는지 다룬다.
- **이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다**
이 절차 다음에 오는 편이고, 여기서 뜬 덤프를 전제로 시작한다. 덤프를 지우지 않는 까닭이 그쪽에 있다.
- **PostgreSQL 을 정상 종료시키고 네 경로를 잰다**
프로세스가 죽었을 때의 모양이다. 먼저 봐 둬야 여기의 `200` 이 얼마나 이상한지 안다.
- **PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다**
실제 복구 지점 목표의 두 번째 겹이 그 편에서 나온다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 친다. `kubectl``sudo` 를 붙이지 않는다 — 가이드가 「kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다」고 스스로 괄호를 달아 두었다. 마지막 한 단계만 호스트(`test-server`)로 넘어가고, 거기서는 사람이 비밀번호를 친다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 주입 수단 | `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` |
| 되돌리는 수단 | 방금 뜬 덤프 파일 하나 — `/tmp/keycloak-backup.sql` |
| 전 구간 | 약 20분. 파괴 구간 자체는 1분 안쪽 |
| 잃는 것 | realm · client · user · 세션 전부 |
| `jq` | 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다 |
**이 절차에는 스크립트가 없다.** 원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸고, 그래서 증거 파일의 줄에는 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가 붙어 있다. 사람이 치는 형태가 아니다. 그리고 이 실험에서 스크립트는 특히 위험하다 — `DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때 무엇이 실행됐는지 알 수 없다. 파괴를 손으로 치고, 눈으로 확인하고, 복구도 손으로 친다.
## 이 실험이 가르는 것
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는 것과 그 파일로 실제로 서비스를 되살리는 것 사이에는 시험되지 않은 가정이 여러 개 있고, 이 절차는 그중 둘을 판정한다.
| # | 질문 | 어떻게 가르나 |
|---|---|---|
| ① | 덤프에 필요한 것이 다 들어가는가 | 특히 세션. 안 들어가면 복구 후 전원 재로그인이다 |
| ② | 복구 절차가 실제로 도는가 | 오류 없이 끝나고 데이터가 일치하는가 |
부수 질문이 하나 붙는다 — 데이터베이스가 비면 무엇이 깨지는가. 프로세스를 내린 A-2 와 여기가 갈라진다.
```text
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ?
```
커넥션은 되는데 테이블이 없는 상태는 단일 장애 주입으로 잘 안 만들어진다. 그래서 이 절차가 따로 있다.
가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. 덤프 파일 안에 세션 행이 실제로 들어 있는 것, 데이터베이스를 통째로 비웠는데 정문이 `200` 인 것, 파드가 `1/1 Running` 인 채로 테이블이 0개인 것, `certs` 200 · `well-known` 500 · 토큰 400 으로 부분만 깨지는 것, 복구가 1초 만에 오류 0건으로 끝나는 것, 세션까지 되살아나는 것, 그리고 덤프가 데이터베이스와 같은 기계 위에 놓여 있는 것.
**복구가 이 편에서는 관찰의 일부다.** 질문 ②의 답이 복구 절에서 나오므로 아래 「복구와 원상복구 확인표」는 원상복구만이 아니라 판정을 함께 싣는다.
## 전제와 되돌리기
- A-2 를 먼저 하면 좋다. 데이터베이스 프로세스가 죽었을 때의 모양을 봐 둬야 이 실험의 `200` 이 얼마나 이상한지 안다.
- A-3 도 먼저다. 실제 복구 지점 목표의 두 번째 겹이 거기서 나온다.
- 네임스페이스는 `keycloak-lab` 이다.
- 덤프를 다른 기계로 옮기는 마지막 단계만 호스트(`test-server`)가 필요하고, 호스트의 `sudo` 는 비밀번호를 묻는다. 그 부분은 사람이 직접 친다.
**이 실험은 데이터베이스를 비운다.** `DROP SCHEMA public CASCADE` 는 realm·client·user·세션을 전부 지운다. 되돌리는 수단은 방금 뜬 덤프 파일 하나뿐이고, 그래서 덤프를 검증하기 전에는 주입 절로 넘어가지 않는다.
되돌리기는 한 줄이고, 파괴하기 전에 읽어 둔다.
```bash label="[kc-lab-1] 파괴하기 전에 읽어 두는 되돌리기 한 줄"
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql
```
**`-i` 가 이 명령의 전부다.** 빠뜨리면 아무 일도 안 일어나고 오류도 안 난다. 왜 그런지는 주입 검증 절의 마지막 단계에서 본다.
## 주입 전에 같은 명령으로 먼저 본다
시험군만 재는 측정은 측정이 아니다. 파괴 후에 볼 것을 파괴 전에 똑같은 명령으로 먼저 봐 둔다. 복구가 완전 일치인지 판정하려면 일치시킬 상대가 있어야 하는데, `DROP SCHEMA` 를 친 뒤에는 그 상대를 만들 방법이 없다. 넓은 것부터 좁혀 가고, 마지막 세 칸은 덤프 자체를 향한다.
```text
파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
```
### 1. 파드가 어디에 몇 개 있는가
**무엇을 보는가** — 파드 넷의 상태와 배치.
```bash label="[kc-lab-1] 파드 배치를 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — 실측은 이렇다(observed, `02-destruction.txt`). 파괴 직후 목록인데 파괴 전후가 같다는 것이 이 실험의 결과이므로 파괴 전 값으로도 읽는다. 증거에 옮겨진 네 줄에는 `-o wide` 가 덧붙이는 `NODE` 열이 없고 `postgres` 행도 빠져 있다. 아래에 없다고 해서 그 파드가 없지는 않다 — 노드 이름과 `postgres` 행은 자기 화면에서 읽는다.
```text
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` 이다. 자기 화면의 `NODE` 열에서 `postgres` 파드가 어느 노드에 떠 있는지 읽고 적어 둔다 — 덤프가 그 노드와 같은 디스크에 놓였는지를 §7 에서 그 이름으로 가른다. 뒤에서 `RESTARTS` 가 오르면 파괴가 엉뚱한 데를 건드렸다는 신호다.
### 2. 데이터가 몇 건 있는가
**무엇을 보는가** — realm·client·user·세션의 개수. 처음 한 번은 읽는 형태로 친다. 값만 뽑는 형태부터 배우면 `psql` 이 무엇을 돌려주는지 모르게 된다.
```bash label="[kc-lab-1] ① psql 이 무엇을 돌려주는지 한 번 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
**어디를 보나** — 모양은 이렇다(observed).
```text
count
-------
2
(1 row)
```
숫자 하나와 `(1 row)` 를 본다. 여기서 오류가 나면 뒤의 모든 단계가 무의미하다. `psql: error: connection to server ... failed` 면 데이터베이스가 아직 안 붙은 것이고, `relation "realm" does not exist` 면 스키마가 이미 없다.
이제 넷을 한 줄로 모은다. 비교할 값이 필요할 때만 이 형태를 쓴다.
```bash label="[kc-lab-1] ② 대조할 한 줄을 뽑는다"
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')"
```
**어디를 보나** — 실측은 이렇다(observed, `01-backup.txt`).
```text
realms|clients|users|sessions|authclients = 2|15|2|3|1
```
**이 값이 뜻하는 것** — 원래 실행은 스크립트로 돌렸고 인가된 클라이언트를 하나 더 셌다. 그래서 증거 줄에는 값이 다섯이고 이름표가 붙어 있다. 위 명령으로 넷을 뽑으면 이름표 없이 `2|15|2|3` 만 나온다. 다섯째 쿼리는 해설 문서의 재현 절차에 남아 있지 않아 가이드가 넷으로 뒀다 — 없는 컬럼을 지어내지 않고, 다섯째가 필요하면 세는 쿼리를 정해서 양쪽에 같이 쓴다. `-tAc` 는 헤더 없이(`-t`) 정렬 없이(`-A`) 한 줄만이라는 뜻이다.
**이 줄을 그대로 복사해 둔다.** 복구 후에 같은 명령을 쳐서 문자 단위로 같은지 본다. 하나라도 다르면 복구가 부분적으로만 됐다.
### 3. 세션이 데이터베이스 안에 있는가
**무엇을 보는가** — 질문 ①의 재료. 세션 행이 실제로 테이블에 있어야 덤프에 들어갈 것이 있다.
```bash label="[kc-lab-1] 세션 행을 나열한다"
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 가 어떻게 생겼는지만 본다(observed).
```text
user_session_id | offline_flag | realm_id
--------------------------+--------------+--------------------------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | 0 | 7845f394-723a-4d07-b530-c7416b2e1d31
...
```
**이 값이 뜻하는 것** — 행이 0개면 질문 ①을 판정할 수 없다. 그때는 관리 콘솔에 한 번 로그인해서 세션을 만들고 다시 본다. 세션이 데이터베이스 테이블에 있다는 것은 `persistent-user-sessions` 가 켜져 있다는 뜻이고(A-0), 그래서 세션이 백업 대상이 된다. volatile 이었다면 세션은 애초에 데이터베이스에 없고 복구해도 전원 재로그인이라 백업의 값어치가 달라진다.
**두 쿼리가 다른 것을 센다.** 앞의 개수 쿼리는 `offline_flag='0'` 만 셌고 이 쿼리는 전부 나열한다. 원래 실행에서도 개수는 `3`, 나열은 `4 rows` 였다(`03-restore.txt`). 두 숫자가 다른 것을 이상하게 여기지 말고 복구 전후에 같은 쿼리끼리 비교한다.
**문제가 생기면** — 나열은 되는데 개수가 0이면 `offline_flag` 필터를 의심한다.
### 4. 밖에서는 무엇이 보이는가
**무엇을 보는가** — 정문과 app1 의 응답. 처음 한 번은 응답을 읽는다.
```bash label="[kc-lab-1] ① 헤더를 통째로 본다"
curl -I https://auth.hyeonworks.com/realms/master
```
헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다. 같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
```bash label="[kc-lab-1] ② 코드만 뽑아 둘을 잰다"
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/
```
**어디를 보나** — 실측은 이렇다(observed, `02-destruction.txt`). 이것도 파괴 직후 값이고, 그게 결과다.
```text
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
```
**이 값이 뜻하는 것** — 지금은 당연히 `200` 이다. 파괴 뒤에도 같은 값이 나오므로 이 두 줄은 정상 판정에 쓸 수 없는 지표의 예로 남는다.
### 5. 덤프를 뜬다
**목적** — 되돌리는 수단을 만든다. 이 파일 없이는 다음 절로 못 간다.
① 시각을 남기고 덤프를 뜬다.
```bash label="[kc-lab-1] ① 시각과 함께 덤프를 뜬다"
date '+%H:%M:%S 백업 시작'
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/keycloak-backup.sql
date '+%H:%M:%S 백업 완료'
```
**예상 결과** — 실측은 이렇다(observed, `01-backup.txt`).
```text
시작: 14:59:30
완료: 14:59:30
크기: 394945 bytes (6956 줄)
```
시각 두 줄과 파일 크기를 본다. 이 규모에서는 1초 미만이다.
**왜 필요한가** — 두 옵션은 짝이다.
| 옵션 | 무엇을 하나 | 없으면 |
|---|---|---|
| `--clean` | 복구 시 기존 객체를 DROP 하고 다시 만든다 | `already exists` 오류가 쏟아진다 |
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 안 낸다 | 깨끗한 데이터베이스에 복구할 때 오류가 쏟아진다 |
`--clean` 만 주면 빈 데이터베이스에 넣는 복구가 깨지고, `--if-exists` 만 주면 아무 효과가 없다 — `DROP` 문 자체가 안 만들어진다. 이 실험은 어차피 빈 데이터베이스에 복구하는데도 두 옵션이 필요하다. 실제 사고는 대개 그렇지 않고, 반쯤 남은 데이터베이스에 덤프를 밀어 넣는 상황이 훨씬 흔하며 그때 이 둘이 있고 없고가 갈린다.
**문제가 생기면** — 이 단계는 읽기만 하므로 파일이 마음에 안 들면 지우고 다시 뜬다.
```bash label="[kc-lab-1] 덤프가 마음에 안 들면 지우고 다시 뜬다"
rm -f /tmp/keycloak-backup.sql
```
### 6. 덤프를 검증한다 — 넷을 통과해야 다음 절로 간다
**목적** — 「파일이 생겼다」와 「복구할 수 있다」를 가른다. `pg_dump` 가 중간에 실패해도 파일은 남고 크기도 0 이 아니다. 이 단계를 건너뛰면 주입 절은 자살행위라고 가이드는 적는다.
① 크기와 줄 수.
```bash label="[kc-lab-1] ① 크기와 줄 수"
ls -l /tmp/keycloak-backup.sql
wc -l /tmp/keycloak-backup.sql
```
**예상 결과** — 실측은 이렇다(observed).
```text
크기: 394945 bytes (6956 줄)
```
② 테이블 수.
```bash label="[kc-lab-1] ② 덤프 안의 테이블 수"
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql
```
**예상 결과** — 실측은 이렇다(observed).
```text
포함된 테이블 수: 101
```
101 이라는 절대값이 아니라 앞에서 본 데이터베이스와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘렸다.
③ 끝까지 쓰였는가.
```bash label="[kc-lab-1] ③ 마지막 세 줄"
tail -3 /tmp/keycloak-backup.sql
```
**예상 결과** — 모양은 이렇다(observed).
```text
--
-- PostgreSQL database dump complete
--
```
`dump complete` 를 본다. 이 줄이 없으면 덤프가 중간에 끊긴 것이고 그 파일로는 복구가 안 된다. 한 줄이 「파일이 생겼다」와 「덤프가 끝났다」를 가른다.
④ 세션이 들어갔는가. 이것이 질문 ① 자체다.
```bash label="[kc-lab-1] ④ COPY 블록에 세션 행이 붙어 있는가"
grep -c 'offline_user_session' /tmp/keycloak-backup.sql
grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110
```
**예상 결과** — 실측은 이렇다(observed, `01-backup.txt`).
```text
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` 블록이다. 복구 절에서 같은 id 들이 되살아나는 것을 확인한다.
**문제가 생기면** — 넷 중 하나라도 어긋나면 덤프를 지우고 다시 뜬다. 다음 절로 넘어가지 않는다.
### 7. 덤프가 지금 어디에 있는가
**무엇을 보는가** — 파일의 경로와 그 파일이 올라앉은 디스크.
```bash label="[kc-lab-1] 덤프의 경로와 디스크를 본다"
ls -l /tmp/keycloak-backup.sql
df -h /tmp
```
**이 값이 뜻하는 것** — 경로가 `/tmp` 다. 이 파일은 지금 `kubectl` 을 친 그 기계의 디스크에 있다. A-4 에서 `local-path` PVC 가 노드에 못박혀 있는 것을 봤고, 그 노드가 안 돌아오면 데이터베이스 볼륨도 안 돌아온다. 그때 유일한 길이 덤프인데 덤프도 같은 기계에 있으면 같이 사라진다. **같은 장애 도메인에 있는 백업은 백업이 아니다.** 원래 실행에서도 덤프는 `test-server:/tmp` 에 있었고, 해설 문서는 그것을 가장 중요한 미검증 항목으로 기록했다. 옮기는 절차는 복구 절에 있고, 파괴 전에는 읽어만 두고 실제 이동은 복구가 끝난 뒤에 한다.
## 주입
여기부터 데이터가 사라진다. 되돌리는 명령은 전제 절에 있고, 덤프 검증 넷을 통과하지 않았으면 지금 돌아가서 한다.
### 1. 스키마를 통째로 지운다
**목적** — 커넥션은 살아 있는데 테이블만 없는 상태를 만든다.
① 시각을 남기고 친다.
```bash label="[kc-lab-1] ① 시각을 남기고 스키마를 지운다"
date '+%H:%M:%S 파괴'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
**예상 결과** — 실측은 이렇다(observed, `02-destruction.txt`).
```text
=== ★ 파괴 — 스키마를 통째로 지운다 ===
시각: 14:59:47
DROP SCHEMA
CREATE SCHEMA
```
`DROP SCHEMA` 와 `CREATE SCHEMA` 두 줄을 본다. `NOTICE: drop cascades to 101 other objects` 같은 줄이 함께 나오는 것이 정상이다.
**왜 필요한가** — 시각을 반드시 적어 둔다. 복구 절의 복구 시간 목표가 이 시각에서 시작한다. `CREATE SCHEMA public` 을 붙이는 까닭은 `public` 스키마 자체를 지우면 복구 스크립트가 들어갈 곳이 없기 때문이다. 지우는 것은 안의 객체이고, 빈 스키마는 남겨 둬야 `pg_dump` 출력이 그대로 들어간다.
**문제가 생기면** — `-d` 인자를 본다. 다른 데이터베이스에 걸렸으면 다음 절의 테이블 수가 101 그대로 나온다.
## 주입 검증
결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다.
```bash label="[kc-lab-1] ① 남은 테이블을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from pg_tables where schemaname='public'"
```
이 실험대는 스크립트로 셌고(observed), 위 형태는 가이드가 미검증으로 표시한 줄이다(unknown). 결과는 이렇다(observed).
```text
남은 테이블: 0
```
`0` 이어야 한다. 여기서 101 이 그대로 나오면 `DROP` 이 다른 데이터베이스에 걸린 것이고 `-d` 인자를 본다.
애플리케이션 테이블이 정말 없는지 직접 물어본다.
```bash label="[kc-lab-1] ② 테이블에 직접 물어본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
모양은 이렇다(observed).
```text
ERROR: relation "realm" does not exist
LINE 1: select count(*) from realm
^
```
**커넥션은 성립하고 SQL 도 파싱된다. 테이블만 없다.** 이 구별이 이 실험의 전부다. A-2 에서는 여기가 `connection to server ... failed` 였다.
**그런데 밖은 멀쩡하다.**
```bash label="[kc-lab-1] ③ 파드와 밖에서 본 상태를 다시 잰다"
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/
```
실측은 이렇다(observed, `02-destruction.txt`).
```text
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 이다. 여기서 파괴가 실패했다고 읽으면 틀린다 — 테이블이 0개인 것을 바로 앞에서 봤다. 파괴는 성공했고 관측 지점이 그것을 못 본다. Keycloak 이 realm 정보를 Infinispan `realms` 캐시에서 서빙하기 때문이고(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다), 캐시는 읽을 때 데이터베이스와 대조하지 않는다. A-1 에서 로그아웃한 세션이 반대편에서 `200` 을 받았던 것과 같은 성질이다.
엉뚱한 것을 죽이지 않았는지도 본다.
```bash label="[kc-lab-1] ④ Service 에서 빠진 파드가 있는가"
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 에서는 여기가 빈 목록이었다. `kubectl get endpoints` 는 v1.33+ 에서 deprecated 이고, 이 실험대에서 실제로 그 경고를 봤다.
### 이 층의 조용한 실패는 복구 쪽에서 온다
여기까지의 주입 검증은 쉽게 통과한다. 어려운 확인은 반대편에 있다. 복구 명령에서 `-i` 를 빠뜨리면 파드 안의 `psql` 이 빈 입력을 받고 정상 종료하고, 셸은 오류를 내지 않고, 종료 코드도 0 이며, `date` 두 줄은 「1초 만에 끝났다」로 찍힌다. 복구된 것과 구별되지 않는다.
```bash label="[kc-lab-1] 치지 않는다 — -i 가 있는 줄과 없는 줄을 눈으로 견준다"
kubectl -n keycloak-lab exec deploy/postgres -- psql ... < dump.sql # ✘
kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔
```
위 두 줄은 치는 명령이 아니다. `...` 와 `dump.sql` 은 두 형태를 나란히 놓으려고 줄여 쓴 것이고, 실제로 치는 복구 명령은 전제 절과 아래 복구 §1 에 온전한 형태로 있다. `-i` 는 표준입력을 파드 안으로 연결하라는 뜻이다. 구별하는 유일한 방법이 복구 뒤의 데이터 대조이고, 그래서 대조는 선택이 아니다.
## 관찰
**전부 깨지지는 않는다.** 세 경로를 나눠서 친다.
```bash label="[kc-lab-1] ① 두 경로를 잰다"
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
```
실측은 이렇다(observed, `03-restore.txt`).
```text
/.well-known/openid-configuration HTTP 500
/protocol/openid-connect/certs HTTP 200
토큰 발급 (DB 쓰기 필요) HTTP 400
```
토큰 발급은 값이 필요하므로 따로 친다. 이 실험대는 스크립트로 돌렸고(observed), 아래는 가이드가 미검증으로 표시한 형태다(unknown).
```bash label="[kc-lab-1] ② 토큰 발급을 잰다"
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=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 길이만 확인하려면 한 줄을 더 친다.
```bash label="[kc-lab-1] ③ 값이 아니라 길이만 잰다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
세 값이 다 다르다.
| 경로 | 코드 | 왜 |
|---|---|---|
| `certs` (JWKS) | 200 | realm 키가 캐시에 있다. 데이터베이스를 안 본다 |
| `.well-known` | 500 | 이 응답을 만들려면 데이터베이스를 본다 |
| 토큰 발급 | 400 | 세션을 써야 한다 |
**부분적으로만 깨진다.** 헬스체크는 통과하고, 일부 엔드포인트는 정상이며, 로그인만 안 된다. 운영에서 이 모양이 고약한 까닭은 「사이트가 떴는가」를 재는 감시가 전부 초록인데 사용자만 못 들어오기 때문이다. 이 사고의 감시 항목은 `/realms/master` 가 아니라 토큰 발급이어야 한다.
로그가 이유를 말한다.
```bash label="[kc-lab-1] ④ Keycloak 로그를 읽는다"
kubectl -n keycloak-lab logs keycloak-0 --tail=50
```
실측은 이렇다(observed, `02-destruction.txt`).
```text
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` 이라 대시보드의 에러 카운터에 안 잡힐 수 있다. 정문의 `200`, 부분 정상, 여기의 `WARN` — 세 관측이 전부 「괜찮다」 쪽으로 기운다.
「데이터베이스가 살아 있다」와 「데이터가 있다」는 다르고, 그 차이가 이 실험의 모양을 만든다.
```text
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠진다
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다
```
헬스체크는 커넥션만 본다. 그래서 빈 데이터베이스를 통과시킨다. Keycloak 의 버그가 아니다 — 「데이터베이스에 붙을 수 있는가」는 프로브가 답할 수 있는 물음이고 「데이터가 온전한가」는 프로브가 답할 수 없는 물음이다. 뒤엣것을 재려면 업무 트랜잭션 하나를 실제로 돌리는 감시가 따로 있어야 한다.
| 재는 것 | 이 사고에서 |
|---|---|
| 파드 `Ready` | 초록 |
| 정문 `200` | 초록 |
| JWKS `200` | 초록 |
| 토큰 발급 | 400 ← 유일하게 정직한 지표 |
## 복구와 원상복구 확인표
### 1. 덤프를 되돌린다
**목적** — 파괴 전의 데이터로 되돌리고, 질문 ②의 답을 만든다.
① 시각을 남기고 복구한다. `-i` 가 있는지 치기 전에 눈으로 확인한다.
```bash label="[kc-lab-1] ① 시각과 함께 덤프를 되돌린다"
date '+%H:%M:%S 복구 시작'
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 복구 완료'
```
**예상 결과** — 실측은 이렇다(observed, `03-restore.txt`).
```text
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
```
② 로그의 오류를 센다.
```bash label="[kc-lab-1] ② 복구 로그의 오류를 센다"
grep -ci '^ERROR' /tmp/restore.log
tail -5 /tmp/restore.log
```
**예상 결과** — `0` 이어야 한다. 0 이 아니면 어떤 줄이 실패했는지 본다. `--clean --if-exists` 로 뜬 덤프를 빈 데이터베이스에 넣으면 오류가 0 인 것이 정상이다.
**왜 필요한가** — 시각 두 줄과 오류 0건은 `-i` 를 빠뜨렸을 때도 똑같이 나온다. 그래서 이 둘로는 복구를 판정하지 않는다.
**문제가 생기면** — 1초 만에 끝났는데 다음 단계의 대조가 어긋나면 `-i` 를 의심한다.
### 2. 진짜 판정 — 주입 전과 문자 단위로 견준다
**목적** — 복구가 완전 일치인지 가른다.
① 주입 전에 친 것과 똑같은 명령을 친다.
```bash label="[kc-lab-1] ① 대조할 한 줄을 다시 뽑는다"
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')"
```
**예상 결과** — 실측은 이렇다(observed, `03-restore.txt`). 원래 실행은 스크립트였으므로 이름표가 붙은 두 줄이고 값이 다섯이다. 위 명령을 손으로 치면 이름표 없이 `2|15|2|3` 한 줄만 나온다. 주입 전 §2 에서 복사해 둔 줄과 그 한 줄을 견준다.
```text
복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1
백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1
```
**왜 필요한가** — 두 줄이 문자 단위로 같은가를 본다. 완전 일치이고, 질문 ②의 답이 「돈다」인 근거가 이 두 줄이다. 여기가 다르면 그 앞의 모든 성공 표시는 무의미하다.
**문제가 생기면** — `-i` 를 빠뜨렸는지 먼저 의심하고, 붙여서 다시 친다.
### 3. 손대지 않고 기다린다
**목적** — 스스로 회복하는지 본다. 여기서 파드를 재시작하면 그 물음 자체가 사라진다.
① 15초쯤 뒤에 본다.
```bash label="[kc-lab-1] ① 15초 뒤에 다시 잰다"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**예상 결과** — 실측은 이렇다(observed, `03-restore.txt`).
```text
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
keycloak-0 restarts=0
keycloak-1 restarts=0
```
**왜 필요한가** — 500 이던 `well-known` 이 `200` 이 된 것과 `RESTARTS` 가 여전히 0 인 것을 같이 본다. **`200` 을 본 순간의 시각을 손으로 적어 둔다** — 아래 §5 의 복구 시간 목표가 끝나는 지점이 그 시각인데, 이 단계에는 그것을 남기는 `date` 줄이 가이드에 없다. 커넥션 풀이 이미 붙어 있었으므로 테이블이 돌아오자마자 동작했다. 파드를 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가 문서에 남았을 것이다.
### 4. 세션이 살아났는지 본다
**목적** — 덤프의 `COPY` 블록에서 본 id 가 테이블로 넘어왔는지 눈으로 잇는다.
① 세션 행을 다시 나열한다. **주입 전 §3 에서 친 것과 열이 하나 다르다** — 거기는 `realm_id` 까지 셋을 뽑고 여기는 `user_session_id` 와 `offline_flag` 둘만 뽑는다. 가이드 원문이 그렇게 갈려 있어 그대로 싣는다. 열이 다르므로 행 수와 `user_session_id` 값으로 견준다.
```bash label="[kc-lab-1] ① 세션 행을 다시 나열한다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag from offline_user_session"
```
**예상 결과** — 실측은 이렇다(observed). 원래 실행은 realm 이름을 함께 뽑았으므로 아래 둘째 열이 `realm` 이다. 위 명령을 그대로 치면 둘째 열에 `offline_flag` 가 오고 값은 `0` 으로 찍힌다. 열 이름이 다른 것을 복구가 덜 됐다는 신호로 읽지 않는다 — 견줄 것은 `user_session_id` 네 값과 `(4 rows)` 다.
```text
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
(4 rows)
```
**왜 필요한가** — `E1q5xI7tt4U_WhZpW7rEPIF2` 가 덤프의 `COPY` 블록에도 복구된 테이블에도 있다. 파일에서 데이터베이스로 실제로 넘어온 것을 눈으로 잇는다. 세션이 백업에서 복원되고 로그인 상태가 유지된다.
### 5. 적어 둔 시각 셋을 나란히 놓는다
```text
14:59:47 파괴
15:00:12 복구 시작
15:00:13 복구 완료
~15:00:28 서비스 정상 확인
RTO = 41초
```
**값은 넷인데 `date` 가 남기는 것은 셋이다.** 파괴·복구 시작·복구 완료 세 줄만 명령이 찍고, 넷째 `~15:00:28 서비스 정상 확인` 은 §3 에서 사람이 읽어 적은 시각이다. `RTO = 41초` 는 첫째와 넷째의 차이므로, 그 시각을 안 적어 뒀으면 여기서 복구 시간 목표를 못 만든다.
41초 중 복구 명령 자체는 1초다. 나머지는 파괴를 알아채고 무엇을 할지 정하는 시간이며, 이 실험에서는 이미 알고 있었으므로 25초였다. 실제 사고에서는 이 부분이 대부분을 차지한다.
복구 지점 목표는 두 겹이다.
```text
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
실제 RPO = ① + ②
```
A-3 은 클라이언트가 200 을 받은 로그인 153건 중 4건이 데이터베이스에 없었다는 것을 측정했다. 백업 주기만 보고 복구 지점 목표를 말하면 ②를 빠뜨린다.
그리고 이 실험대의 규모는 현실적이지 않다.
| | 이 실험대 | 운영 |
|---|---|---|
| 덤프 크기 | 395KB | GB~TB |
| 복구 시간 | 1초 | 분~시간 |
| 세션 수 | 3~4 | 수만 |
복구가 1초인 것은 데이터가 작기 때문이고, 이 실험이 확인한 것은 절차가 맞다는 것까지다. 시간은 규모에 따라 완전히 달라진다.
### 6. 덤프를 다른 기계로 옮긴다 — 이 실험이 「못 했다」로 남긴 단계
**목적** — 덤프를 데이터베이스와 다른 장애 도메인에 둔다.
사람이 쳐야 하는 부분이 여기서 갈린다.
| 하는 일 | 어디서 | sudo |
|---|---|---|
| 덤프 뜨기 · 복구 | `kc-lab-1` | 게스트는 무암호 — 스크립트로도 된다 |
| 덤프를 호스트의 사용자 홈에 두기 | `test-server` | 필요 없다 |
| 덤프를 root 소유 경로(`/var/backups` 등)에 두기 | `test-server` | 비밀번호를 묻는다 — 사람이 친다 |
호스트에서 비대화 `sudo` 는 반드시 실패한다. 그 벽에 부딪힌 기록이 D-4 의 증거에 남아 있다(observed, `d4-certificate-renewal/01-certificate-state.txt`).
```text
$ sudo -n -l
sudo: a password is required
```
`-n` 은 비밀번호를 물어보지 말라는 뜻이고 호스트에서는 그게 곧 실패다. 그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다. `ssh -t` 로 붙어 사람이 비밀번호를 쳐야 하고, `-t` 가 없으면 sudo 가 비밀번호를 읽을 tty 가 없다.
① 두 줄을 차례로 친다. 이 실험대는 여기까지 하지 않았다(unknown). 가이드가 미검증으로 표시한 줄이고, 호스트 이름과 경로는 따라 하는 사람의 배치에 맞춘다.
```bash label="[kc-lab-1 → test-server] ① 호스트의 사용자 홈으로 옮긴다"
# ① 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 label="[kc-lab-1] ② 양쪽에서 크기를 센다"
wc -c /tmp/keycloak-backup.sql
ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql'
```
**예상 결과** — 두 숫자가 같다. 다르면 전송이 잘린 것이다.
**왜 필요한가** — 이것으로도 부족하다. 호스트는 VM 두 대를 품고 있는 기계이므로 호스트가 죽으면 게스트도 덤프도 같이 간다. 진짜 요건은 「다른 기계」가 아니라 「다른 장애 도메인」이다.
**이 두 줄은 호스트에 평문 덤프를 두 벌 남긴다.** `install` 은 옮기기가 아니라 복사라 사용자 홈의 `keycloak-backup-2026-09-04.sql` 이 `/var/backups` 의 사본과 함께 그대로 있다. 두 파일 다 realm·client·user·세션을 통째로 담고 있는데, 지우는 절차는 가이드에 없고(unknown) 아래 여덟 항목에도 없다. 실험대 밖에서 이 단계를 밟았다면 두 파일을 어떻게 할지는 치는 사람이 정한다.
### 7. 여덟 항목을 대조한다
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 테이블 | `psql -c "select count(*) from pg_tables where schemaname='public'"` | 101 |
| 데이터 | 복구 대조의 `-tAc` 한 줄 | 백업 시점과 문자 단위로 동일 |
| 세션 | `select count(*) from offline_user_session` | 파괴 전과 같은 수 |
| 파드 | `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` |
| 로그인 | 관찰 절의 토큰 발급 | `200` ← 이것이 진짜 판정 |
| 덤프 | `ls -l /tmp/keycloak-backup.sql` | 남겨 둔다. D-2 의 전제다 |
**덤프를 지우지 않는다.** D-2 가 이 파일을 전제로 한다.
## 막히면
가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 이 절차에서 실제로 갈리는 곳이라고 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 복구가 1초 만에 끝났는데 데이터가 없다 | `exec` 에 `-i` 가 없다. 오류도 안 난다 | 데이터 대조. `-i` 를 붙여 다시 |
| 복구에서 `already exists` 가 쏟아진다 | 덤프를 `--clean --if-exists` 없이 떴다 | `grep -c '^DROP TABLE' /tmp/keycloak-backup.sql` — 0 이면 그것 때문이다 |
| 덤프 파일은 있는데 복구가 중간에 멈춘다 | 덤프가 잘렸다 | `tail -3` 에 `dump complete` 가 있는가 |
| 파괴했는데 정문이 계속 `200` | 정상이다. realm 캐시가 서빙한다 | 토큰 발급으로 판정 |
| `psql: relation "realm" does not exist` | 파괴가 걸린 것이다 | 그게 주입 검증의 기대 출력이다 |
| `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'` 필터가 있다 | 같은 쿼리끼리 비교 |
| 백업이 0바이트다 | `pg_dump` 가 인증에서 막혔다 | `-U keycloak -d keycloak` 를 확인. 파일을 지우고 다시 뜬다 |
| 호스트에서 `sudo` 가 안 먹는다 | 호스트 sudo 는 비밀번호를 요구한다 | `ssh -t` 로 붙어 사람이 친다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:5715:00 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 파괴 직후 파드 네 줄과 `RESTARTS 0`, 백업의 `시작: 14:59:30` · `완료: 14:59:30` · `크기: 394945 bytes (6956 줄)`, 테이블 수 `101`, `offline_user_session 언급: 13` 과 `COPY` 블록에 붙은 세션 행, 파괴 시각 `14:59:47` 과 `DROP SCHEMA` · `CREATE SCHEMA`, 남은 테이블 `0`, 파괴 뒤에도 정문과 app1 이 전부 `HTTP 200` 인 것, `certs` 200 · `.well-known` 500 · 토큰 발급 400, `KEYCLOAK_JDBC_PING2` 의 `WARN` 두 줄, 복구의 `시작: 15:00:12` · `완료: 15:00:13` · `오류 줄: 0`, 복구 전후 대조 두 줄이 같은 것, `+15초 well-known=200 토큰발급=200` 과 `restarts=0`, 복구된 세션 네 행, `RTO = 41초`.
- (observed) A-3 이 잰 로그인 153건 중 4건 소실은 그 실험의 값이고, 여기서는 실제 복구 지점 목표의 두 번째 겹으로 인용만 한다.
- (unknown) 남은 테이블을 세는 `pg_tables` 쿼리와 토큰 발급 `curl` 한 줄. 가이드가 미검증으로 표시했고 원래 실행은 스크립트로 돌렸다. 덤프를 호스트로 옮기는 두 줄도 미검증이고, 이 실험대는 그 단계를 하지 않았다 — 덤프는 데이터베이스와 같은 기계에 놓인 채 실험이 끝났다.
- 다섯째 컬럼은 지어내지 않았다. 증거 줄에는 `authclients` 까지 다섯 값이 있는데 해설 문서의 재현 절차에 그 쿼리가 없어서, 가이드도 이 절차도 넷만 센다.
- 비밀은 옮기지 않았다 — 관리자 비밀번호는 명령 치환으로만 넘어가고, 길이를 재는 줄만 따로 있다. 세션 id 와 realm UUID 는 식별자라 그대로 적었다. 덤프 파일 자체가 realm·client·user·세션을 통째로 담고 있고, 그 파일을 어디에 두는가가 이 실험의 마지막 물음이다.
- (unknown) 검증 넷을 통과한 덤프로 복구했는데 그 복구가 실패했을 때 갈 길은 가이드에 없다. 「막히면」 표는 원인을 가리키는 데까지만 적고, 지우고 다시 시작하는 절차를 주지 않는다. 만들어 넣지 않았다.
- 이 실험이 확인하지 않은 것 — 백업 자동화, 보존 주기, 복구 리허설의 정기 실행. 이번엔 손으로 한 번 떴고 한 번 되돌렸다. 그것만 참이다.
<!-- body:end -->
@@ -0,0 +1,706 @@
---
id: e53c5947-e1df-400a-ad79-e9d55b1da452
kind: SETUP
slug: reproduce-d2-version-upgrade
title: 이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e53c5947-e1df-400a-ad79-e9d55b1da452/edit"
pinnedVersions:
- name: Keycloak (시작·복귀 태그)
version: 26.7.0
- name: Keycloak (정방향)
version: 26.7.3
- name: Keycloak (역방향 대조)
version: "26.0"
- name: Infinispan (26.7.3 에 실린 판)
version: 16.0.14
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-2
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 이미지 태그를 올렸다 내리며 롤백이 언제 막히는지 가른다
databasechangelog 행 수를 먼저 세고 이미지 태그를 정방향·롤백·역방향으로 세 번 바꾸는 절차다. 마지막 한 번은 파드를 CrashLoopBackOff 로 만들므로 백업 없이 시작하지 않는다.
## 관계
- **되돌리기를 막은 것은 체크섬이었고 그 판정은 조건부였다**
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
- **롤링 재시작은 세션을 남기고 캐시만 지웠다**
태그를 바꾸면 파드가 하나씩 갈린다. 그때 세션과 캐시가 어떻게 갈리는지를 그쪽이 다룬다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
`Running` 인데 `0/1` 인 파드가 왜 트래픽을 안 받는지, 그것이 왜 사고를 절반에서 멈추는지 다룬다.
- **스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다**
먼저 해 둬야 하는 편이다. 여기서 쓰는 되돌리기 수단이 태그가 아니라 그 편이 남긴 덤프일 수 있다.
- **롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다**
롤링 재시작이 무중단이라는 것이 이 절차의 전제다. 그 전제를 그 편에서 쟀다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 친다. `kubectl``sudo` 를 붙이지 않는다. 호스트로 넘어가는 단계가 없어 전 구간을 게스트 안에서 끝낸다. 터미널을 두 개 열어 두면 편하다 — 하나는 가용성 폴링용, 하나는 관찰용이다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 주입 수단 | `set image statefulset/keycloak` — 태그를 세 번 바꾼다 |
| 되돌리는 수단 | 태그 한 줄. 단, 행 수가 안 바뀌었을 때만 |
| 전 구간 | 약 20분 |
| 대조군 | 1초 간격 150회 폴링. `--max-time 3` |
| `jq` | 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다 |
**실측이 두 실행에서 나온다**(observed). 처음 실행은 `15:0015:10` 에 역방향 `26.0` 을 쳤고, 후속 실행은 `15:2215:26``26.7.3` 정방향과 롤백을 쳤다. 아래에서도 어느 쪽인지 매번 적는다.
## 이 실험이 가르는 것
「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다. 그 계획이 언제 동작하고 언제 동작하지 않는지를 가른다.
Keycloak 은 Liquibase 로 스키마를 관리한다. 적용한 변경 하나하나가 `databasechangelog` 테이블에 행으로 쌓이고, 각 행에는 그 변경 정의의 체크섬(`md5sum`)이 들어 있다.
```text
컨테이너가 뜬다
└─▶ 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, 변화 없음 | 가능 |
판단 기준은 버전 번호가 아니라 행 수의 변화다. 그래서 그 숫자를 재는 법부터 배운다.
가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. 업그레이드 전후로 `databasechangelog` 행 수가 그대로인 것, 파드가 하나씩 갈리는 동안 정문이 계속 `200` 인 것, 같은 스키마에서는 롤백이 되는 것, 전환 순간의 `000` 이 서버 오류가 아닌 것, 스키마가 바뀐 방향에서 `ValidationFailedException` 으로 기동이 거부되는 것, 그때도 서비스가 살아 있는 것, 실패한 기동이 스키마를 안 건드린 것.
## 전제와 되돌리기
- D-1 이 끝나 있고 덤프가 손에 있다. 이 실험의 되돌리기 수단은 태그가 아니라 그 파일일 수 있다.
- A-8 — 롤링 재시작이 무중단이라는 것이 전제다.
- 네임스페이스는 `keycloak-lab` 이다.
- 터미널 두 개를 열어 둔다.
**이 실험은 실제로 버전을 바꾼다.** 이미지 태그를 세 번 바꾸고(정방향 → 롤백 → 그리고 선택적으로 실패하는 방향) 마지막 것은 파드를 `CrashLoopBackOff` 로 만든다. 전 구간 약 20분이다. 그리고 이 실험은 백업 없이 시작하지 않는다 — 스키마가 움직이는 방향으로 가면 태그로는 못 돌아온다.
되돌리기는 전부 태그 한 줄이고, 각 단계 앞에서 먼저 읽는다.
```bash label="[kc-lab-1] 단계마다 먼저 읽어 두는 되돌리기 한 줄"
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
```
**이 되돌리기가 유효한 것은 `databasechangelog` 가 안 바뀌었을 때뿐이다.** 바뀌었으면 되돌리기는 「덤프 복구 + 태그 되돌리기」가 된다.
## 주입 전에 같은 명령으로 먼저 본다
**여기서 안 재면 나중에 다시 못 재는 값이 하나 있다** — 업그레이드 전의 `databasechangelog` 행 수다. 올린 뒤에는 그 값이 지워지기 때문에 「롤백해도 되는가」를 판정할 근거가 사라진다. 넓은 것부터 좁혀 간다.
```text
백업 → 현재 태그 → ★ 마이그레이션 수 → 세션 → 클러스터 뷰 → 가용성 대조군
```
### 1. 백업이 먼저다
**목적** — 태그로 못 돌아오는 경우의 되돌리기 수단을 손에 쥔다. D-1 의 절차 그대로다.
① 덤프를 뜨고 끝까지 쓰였는지 본다.
```bash label="[kc-lab-1] ① 덤프를 뜨고 마지막 세 줄을 본다"
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
```
**예상 결과** — 실측은 이렇다(observed, `01-pre-upgrade.txt` 와 `followup/01-d2-forward-upgrade.txt`).
```text
백업: 396333 bytes
백업: 395375 bytes
```
크기와 `tail` 의 `dump complete` 를 본다. 두 값은 두 실행의 것이라 서로 다르다.
**왜 필요한가** — 이 파일이 없으면 이 실험을 하지 않는다.
**문제가 생기면** — `dump complete` 가 안 보이면 덤프가 잘린 것이고, D-1 의 덤프 검증 넷으로 돌아간다.
### 2. 지금 어떤 태그로 돌고 있는가
**무엇을 보는가** — StatefulSet 에 적힌 태그.
```bash label="[kc-lab-1] ① StatefulSet 의 태그를 본다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].image}'; echo
```
**어디를 보나** — 실측은 이렇다(observed, `01-pre-upgrade.txt`).
```text
quay.io/keycloak/keycloak:26.7.0
```
**이 값이 뜻하는 것** — `latest` 로 되어 있으면 무엇에서 무엇으로 가는지 말할 수 없기 때문에 이 실험이 성립하지 않는다. 그리고 StatefulSet 에 적힌 것과 파드가 실제로 돌리고 있는 것은 다를 수 있다 — 적용 중이거나 롤아웃이 멈춰 있으면 그렇다.
```bash label="[kc-lab-1] ② 파드가 실제로 돌리는 이미지를 본다"
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` 인지를 본다.
### 3. 마이그레이션 수 — 이 실험의 전부다
**무엇을 보는가** — `databasechangelog` 의 행 수. 처음 한 번은 읽는 형태로 친다.
```bash label="[kc-lab-1] ① psql 이 무엇을 돌려주는지 한 번 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from databasechangelog"
```
**어디를 보나** — 모양은 이렇다(observed).
```text
count
-------
210
(1 row)
```
비교용으로 값만 뽑는 형태도 익혀 둔다.
```bash label="[kc-lab-1] ② 값만 뽑는 형태"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
**어디를 보나** — 두 실행 모두 같은 값이 나왔다(observed).
```text
총 마이그레이션 수: 210
```
**이 값을 화면 밖에 적어 둔다.** 무엇이 마지막으로 적용됐는지도 한 번 본다. 나중에 「스키마가 언제 움직였나」를 물을 때 여기를 본다. 이 실험대는 개수만 셌고(observed), 아래는 가이드가 미검증으로 표시한 형태다(unknown).
```bash label="[kc-lab-1] ③ 마지막 다섯 줄을 본다 (미검증)"
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` 의 가장 최근 값이 이 데이터베이스의 스키마가 마지막으로 움직인 시각이다. 210 은 「이 데이터베이스는 여기까지 올라갔다」는 기록이고, 업그레이드 후에 211 이상이 되면 스키마가 움직였으며 그 순간부터 태그만으로는 못 돌아온다.
### 4. 세션도 센다
**무엇을 보는가** — 판올림이 세션을 건드리는지 판정할 재료.
```bash label="[kc-lab-1] 세션 수를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session"
```
**어디를 보나** — 실측은 이렇다(observed).
```text
현재 세션: 4 (첫 실행)
세션 전: 3 (후속 실행)
```
**이 값이 뜻하는 것** — 0 이면 관리 콘솔에 한 번 로그인해서 만든다. 0인 채로 업그레이드하면 「세션이 유지되는가」를 판정할 수 없다.
### 5. 클러스터 뷰와 Infinispan 판
**무엇을 보는가** — 멤버 수와 괄호 안의 판 번호.
```bash label="[kc-lab-1] 클러스터 뷰 마지막 줄을 본다"
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
```
**어디를 보나** — 실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt` 의 업그레이드 후 값).
```text
cluster: [keycloak-1-11418(v=16.0.14)|47] (2) [keycloak-1-11418(v=16.0.14), keycloak-0-58996(v=16.0.14)]
```
**이 값이 뜻하는 것** — 괄호 안의 판과 멤버 수 `(2)` 를 본다. Keycloak 태그를 바꾸면 함께 실린 Infinispan 판도 같이 바뀐다 — 후속 실행에서 `16.0.12` 에서 `16.0.14` 로 올라갔다. 클러스터 프로토콜 호환성 문제가 있다면 여기서 드러나므로, 업그레이드 후에 이 줄이 멤버 2로 다시 서는지 보는 것이 판정 항목 하나다.
### 6. 새 태그가 레지스트리에 있는지 본다
**무엇을 보는가** — 올라갈 곳이 실제로 있는가. 처음 한 번은 응답을 그대로 본다.
```bash label="[kc-lab-1] ① 응답을 통째로 본다"
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"
```
한 줄짜리 JSON 이 통째로 나온다. 어떤 필드가 있는지 보고 나서 자른다. 이 실험대는 `jq` 가 없어 이렇게 읽었고(observed), 가이드가 그 줄을 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ② 이름만 잘라 본다 (미검증)"
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 보다 새 이미지가 없다」고 적었지만 실제로는 셋이나 있었다.
### 7. 가용성 대조군을 먼저 띄운다
**목적** — 주입 중에 나오는 비200 을 귀속할 수 있게 평시 오류율을 잡는다.
① 1초 간격으로 150회, 뒤에서 돌린다.
```bash label="[kc-lab-1] ① 폴링을 뒤에서 돌린다"
( 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 ) &
```
그만 재려면 `kill %1` 이다. **`&` 로 붙인 작업은 그것을 띄운 창의 것이라 `kill %1` 도 그 창에서만 듣는다.** 그래서 이 한 줄은 폴링용 창에서 치고, 아래 ② 부터 관찰 절까지는 다른 창에서 친다. 두 창 다 `kc-lab-1` 이다. 30초쯤 두고 먼저 평시를 센다.
```bash label="[kc-lab-1] ② 평시 응답 코드를 센다"
tr ' ' '\n' < /tmp/d2-avail.txt | grep -c 200
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
```
**예상 결과** — `uniq -c` 의 줄이 하나다.
**왜 필요한가** — 줄이 하나면 전부 같은 코드였다는 뜻이고, 두 줄 이상이면 평시에 이미 오류가 있기 때문에 그 상태로 주입하면 주입 중의 오류를 귀속할 수 없다. `--max-time 3` 을 기억해 둔다 — 관찰 절에서 나오는 `000` 이 이 값 때문이다.
**문제가 생기면** — 파일이 비어 있으면 백그라운드 작업이 죽은 것이고 `jobs` 로 본다.
## 주입
### 1. 태그를 26.7.3 으로 올린다
**목적** — 패치 릴리스로 한 칸 올리고 스키마가 움직이는지 본다.
① 시각을 남기고 태그를 바꾼다.
```bash label="[kc-lab-1] ① 시각을 남기고 태그를 바꾼다"
date '+%H:%M:%S 태그 변경'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.3
```
**예상 결과** — 실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
시작: 15:22:59
statefulset.apps/keycloak image updated
```
`image updated` 한 줄을 본다. 이건 「적용됐다」가 아니라 「접수됐다」다. 실제 교체는 지금부터 일어난다.
② 롤아웃을 기다린다.
```bash label="[kc-lab-1] ② 롤아웃을 기다린다"
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
date '+%H:%M:%S 롤아웃 완료'
```
**예상 결과** — 실측은 이렇다(observed).
```text
partitioned roll out complete: 2 new pods have been updated...
완료: 15:24:26
```
87초 걸렸다.
**왜 필요한가** — `2 new pods have been updated` 가 나와야 교체가 끝난다.
**문제가 생기면** — `rollout status` 가 안 끝나고 매달려 있으면 그게 신호다. StatefulSet 은 파드 하나가 Ready 가 되기 전에는 다음 파드를 안 건드리기 때문에, 매달림은 곧 첫 파드가 안 뜬다는 뜻이다. 다른 터미널에서 `get pods -w` 로 본다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다.
```bash label="[kc-lab-1] ① 파드가 새 이미지를 돌리는가"
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
```
실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
quay.io/keycloak/keycloak:26.7.3
keycloak-0 1/1 Running restarts=0
keycloak-1 1/1 Running restarts=0
```
`RESTARTS` 가 `0` 인 것이 중요하다. 교체는 새 파드를 만드는 것이지 같은 파드를 재시작하는 것이 아니다. `RESTARTS` 가 올라가 있으면 새 파드가 기동에 실패해서 재시작을 반복하고 있다.
실제로 새 파드인지는 나이로 본다.
```bash label="[kc-lab-1] ② 나이로 새 파드인지 본다"
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
실측은 첫 실행의 롤포워드 직후 값이다(observed).
```text
keycloak-0 1/1 Running 0 10m
keycloak-1 1/1 Running 0 28s
```
`AGE` 를 본다. 하나씩 갈리므로 나이가 다르다. 둘 다 방금 생긴 나이면 동시에 갈린 것이고, 그건 무중단이 아니다.
버전은 파드가 자기 입으로 말하게 한다.
```bash label="[kc-lab-1] ③ 로그가 말하는 판을 본다"
kubectl -n keycloak-lab logs keycloak-0 | grep -i 'Keycloak 26' | tail -1
```
실측은 이렇다(observed).
```text
Keycloak 26.7.3
```
이미지 태그와 다르면 태그가 재사용됐다 — 같은 태그가 다른 내용을 가리킨다.
## 관찰
### 1. 정방향은 무중단이었는가
```bash label="[kc-lab-1] 폴링 결과를 센다"
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
```
실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
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` 을 `150` 과 견주지 않는다** — 루프는 150회지만 여기서 세는 것은 그 시점까지 파일에 쌓인 만큼이고, 판정은 「줄이 하나인가」로 선다. 이 숫자는 §3 을 시작하기 전에 적어 둔다. 정방향 업그레이드는 무중단이었다. 87회 요청이 전부 200 이고, 파드가 하나씩 갈리는 동안 남은 파드가 받았다. 다만 「무중단」은 관측 해상도에 달려 있다 — 이건 1초 간격·3초 타임아웃으로 잰 결과이고, 더 촘촘히 보면 더 보일 수 있다. D-4 에서 0.2초 간격으로 재니 다른 것이 보였다.
그림으로도 남아 있다 — 증거의 `d2-upgrade-window.png` 다. `cluster_size` 가 2 → 1 → 2 를 두 번 반복하고 파드별 `up` 시계열이 끝나고 새 시계열이 시작된다. 두 번인 것을 본다. 파드가 둘이므로 교체도 두 번이고 그때마다 클러스터가 잠시 한 명이 된다. 한 번만 보이면 두 파드가 동시에 갈렸다.
### 2. 스키마가 움직였는가 — 이 실험의 판정
주입 전에 친 것과 똑같은 명령이다.
```bash label="[kc-lab-1] 마이그레이션 수를 다시 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
실측은 이렇다(observed, `followup/01-d2-forward-upgrade.txt`).
```text
마이그레이션 후: 210 (전: 210)
세션 후: 3 (전: 3)
```
전과 후가 같은가를 본다.
| 결과 | 뜻 | 되돌리는 법 |
|---|---|---|
| 행 수가 그대로 | 스키마가 안 움직였다 | 태그만 되돌리면 된다 |
| 행 수가 늘었다 | 새 changeset 이 적용됐다 | 덤프 복구 + 태그 되돌리기 |
26.7.0 에서 26.7.3 은 패치 릴리스이므로 스키마가 그대로였다. 그래서 롤백이 가능하다는 가설이 섰고, 바로 시험한다.
### 3. 롤백을 시험한다
**목적** — 같은 스키마에서 태그만 되돌렸을 때 파드가 뜨는지 본다.
**폴링을 다시 띄우고 시작한다.** 주입 전 §7 의 루프는 1초 간격 150회라 150초면 끝난다. 정방향 롤아웃 하나가 87초였으니 여기까지 오는 동안 그 루프는 이미 끝나 있고, 다시 띄우지 않으면 §4 가 세는 것은 롤백 구간이 아니라 정방향 구간이다. **그러면 롤백에서 나온 `000` 한 건이 안 잡히고 화면은 「비200 0」으로 나온다** — 주입이 조용히 안 잡히는 모양이다.
⓪ §1 에서 센 `200 응답: 87 회 / 비200: 0` 을 먼저 손으로 적어 둔다. 루프가 `>` 로 파일을 잘라 쓰기 때문에 다시 띄우면 그 값은 화면에서 사라진다. 폴링 창에서 `jobs` 를 쳐 아직 돌고 있으면 `kill %1` 로 멈춘 뒤, 같은 창에서 친다.
```bash label="[kc-lab-1] ⓪ 폴링 창에서 다시 띄운다"
( 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 label="[kc-lab-1] ① 태그를 26.7.0 으로 되돌린다"
date '+%H:%M:%S 롤백'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
```
**예상 결과** — 실측은 이렇다(observed, `followup/02-d2-rollback-same-schema.txt`).
```text
시작: 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 이다.
### 4. 전환 순간의 000 을 읽는다
```bash label="[kc-lab-1] 비200 이 어디에 있는지 본다"
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
grep -n '000' /tmp/d2-avail.txt
```
판정은 첫 줄이 한다. 둘째 줄의 `grep -n` 은 「있다/없다」까지만이다 — 루프가 `printf '%s '` 로 쓰기 때문에 이 파일은 개행이 없는 한 줄이고, 그래서 `1:` 하나에 전부 붙어 나온다. 몇 번째 요청이었는지는 이 명령으로 안 나온다.
실측은 이렇다(observed, `followup/02-d2-rollback-same-schema.txt`). 아래는 줄바꿈을 넣어 읽기 좋게 옮긴 것이고 화면은 한 줄이다.
```text
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. 일부러 실패시킨다 — 선택 단계
**목적** — 행 수가 바뀌었을 때가 실제로 어떤 모양인지 본다. 위까지로 판정은 끝났으므로 가이드가 이 단계를 선택으로 둔다. 되돌리기는 태그 한 줄이고 먼저 읽는다.
**건너뛸 거면 §6·§7·§8 도 같이 건너뛰고 복구 절로 간다.** 그 셋은 전부 여기서 만든 실패한 기동을 읽는다 — §6 은 그 파드의 로그, §7 은 ready 주소가 하나로 줄어든 상태, §8 은 실패한 기동이 스키마를 건드렸는지다. §5 를 안 치면 §6 은 빈 출력이고 §7 의 ready 주소는 둘이며, 그 화면은 「아무 문제 없음」이 아니라 「볼 것이 없음」이다.
① 시각을 남기고 26.0 으로 내린다.
```bash label="[kc-lab-1] ① 26.0 으로 내린다"
date '+%H:%M:%S 26.0 으로 내린다'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.0
```
② 이번에는 `rollout status` 로 기다리지 말고 눈으로 본다.
```bash label="[kc-lab-1] ② 파드 상태를 눈으로 따라간다"
kubectl -n keycloak-lab get pods -w
```
**예상 결과** — 실측은 이렇다(observed, `02-rollback-attempt.txt`).
```text
시각: 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` 를 오가는 것과, `keycloak-0` 이 내내 `1/1` 인 것이다. `Running` 인데 `0/1` 인 상태를 「떴다」로 읽지 않는다 — 컨테이너 프로세스는 살아 있지만 readiness 를 통과하지 못했고 곧 죽는다. `Ctrl-C` 로 빠져나온다.
**왜 필요한가** — 이 모양을 봐 두면 롤아웃이 멈춘 것과 느린 것을 구별할 수 있다.
**문제가 생기면** — 파드가 이미 죽어 로그가 안 나오면 직전 컨테이너의 로그를 본다.
### 6. 왜 실패했는지 물어본다
```bash label="[kc-lab-1] ① Liquibase 관련 줄만 뽑는다"
kubectl -n keycloak-lab logs keycloak-1 | grep -iE 'liquibase|changeset|validation'
```
실측은 이렇다(observed, `03-roll-forward.txt`).
```text
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 label="[kc-lab-1] ② 파드가 이미 죽었으면 직전 로그를 본다"
kubectl -n keycloak-lab logs keycloak-1 --previous
```
### 7. 그런데 서비스는 살아 있다
```bash label="[kc-lab-1] 밖과 Service 와 StatefulSet 을 함께 본다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
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 -n keycloak-lab get statefulset keycloak
```
실측은 이렇다(observed, `03-roll-forward.txt`).
```text
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 의 롤링 업데이트가 사고를 절반에서 멈춰 줬다.
```text
keycloak-1 을 26.0 으로 → 기동 실패 → Ready 가 안 됨
└─ StatefulSet 은 keycloak-0 을 건드리지 않는다
└─ keycloak-0 (26.7.0) 이 계속 서비스한다
```
| replica 1 이었다면 | |
|---|---|
| 유일한 파드가 CrashLoopBackOff | 전면 장애 |
| 되돌리려면 사람이 개입 | 그동안 계속 다운 |
A-8 에서 「무중단은 replica ≥ 2 와 readiness 의 조합」이라고 썼는데, 여기서는 그 조합이 잘못된 배포를 절반에서 멈춰 줬다.
### 8. 실패한 기동이 스키마를 건드렸는지 센다
같은 명령을 세 번째로 친다.
```bash label="[kc-lab-1] 마이그레이션 수를 세 번째로 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
실측은 이렇다(observed). 네 칸짜리 이 줄은 원래 실행이 돌린 집계 스크립트의 출력이고, 위 명령은 그중 `migrations` 자리의 `210` 하나만 낸다. 나머지 셋을 한 줄로 내는 형태는 원본 가이드에 없다(unknown).
```text
realms|clients|migrations|sessions = 2|15|210|4
```
210 그대로다. Liquibase 가 검증 단계에서 멈췄으므로 스키마를 건드리지 못했고, 그래서 이 사고는 「태그만 되돌리면 되는」 쪽에 남았다. 여기가 두 경우를 가른다.
```text
✔ Liquibase 가 검증에서 멈췄다 → 이미지만 되돌리면 끝
✘ 이미 적용한 뒤였다 → DB 복구(D-1)까지 해야 한다
```
그래서 업그레이드 계획을 어떻게 쓰는가가 이 실험의 산출물이 된다.
```text
✘ "문제가 생기면 이미지 태그를 되돌린다"
└─ 스키마가 이미 바뀌었으면 옛 버전이 안 뜬다
✔ "업그레이드 전에 databasechangelog 를 세어 두고,
바뀌었으면 백업에서 DB 를 되돌린 뒤 태그를 되돌린다"
```
| 단계 | |
|---|---|
| 1 | 백업(D-1). 스키마가 움직인 뒤에는 이것만이 되돌리기 수단이다 |
| 2 | `databasechangelog` 행 수를 적어 둔다 — 나중에는 못 잰다 |
| 3 | 태그 변경 |
| 4 | 첫 파드만 관찰 — StatefulSet 이 멈춰 준다 |
| 5 | 행 수를 다시 센다. 그대로면 태그만 되돌려도 된다 |
| 6 | 늘었으면 DB 복구 + 태그 되돌리기 |
## 복구와 원상복구 확인표
### 1. 시작할 때의 태그로 되돌린다
**목적** — 실험대를 26.7.0 으로 돌려놓는다.
① 시각을 남기고 태그를 되돌린 뒤 롤아웃을 기다린다.
```bash label="[kc-lab-1] ① 태그를 되돌리고 롤아웃을 기다린다"
date '+%H:%M:%S 복귀'
kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
```
**예상 결과** — 실측은 이렇다(observed, `03-roll-forward.txt`).
```text
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` 도 있다. 이 실험은 쓰지 않았고(unknown), 쓰더라도 되돌아가는 것은 이미지뿐이다 — 스키마가 움직였다면 undo 도 같은 벽에 부딪힌다.
**문제가 생기면** — 폴링이 아직 돌고 있으면 `jobs` 로 보고 `kill %1` 로 멈춘다.
### 2. 아홉 항목을 대조한다
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 태그 | `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` | 남겨 둔다 |
`/tmp/d2-avail.txt` 는 이 표에 없다. 처분을 정하는 줄이 원본 가이드에 없어서(unknown) 여기에도 없다. 다음 런이 같은 루프를 띄우면 `>` 가 덮어쓰지만, 그때까지는 이 파일을 손으로 치우는 사람이 없다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `rollout status` 가 안 끝난다 | 첫 파드가 안 뜬다. StatefulSet 이 기다린다 | 다른 터미널에서 `get pods -w` |
| 파드가 `Running` 인데 `0/1` | 프로세스는 살아 있고 readiness 미통과 | `logs` 를 본다. 「떴다」로 읽지 않는다 |
| 로그가 안 나온다 | 파드가 이미 죽었다 | `logs keycloak-1 --previous` |
| 업그레이드 전 행 수를 안 적었다 | 그 값은 이제 데이터베이스에 없다 | 덤프에서 복원한다 — 아래 |
| 비200 이 `000` 이다 | 서버 오류가 아니라 `--max-time` 타임아웃 | `--max-time` 값을 늘려 다시 재 본다 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated. 실측으로 이 경고를 봤다 | `get endpointslice -l kubernetes.io/service-name=...` |
| 두 파드가 동시에 갈렸다 | `podManagementPolicy: Parallel` | `get statefulset keycloak -o yaml \| grep podManagement` |
| 새 태그를 못 찾는다 | 레지스트리에서 확인 안 했다 | 주입 전 절차의 태그 목록 |
**업그레이드 전 행 수를 안 적었을 때**는 덤프 안에 그 테이블이 통째로 들어 있다. 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[kc-lab-1] 덤프에서 행 수를 되찾는다 (미검증)"
sed -n '/^COPY public.databasechangelog /,/^\\\.$/p' /tmp/pre-upgrade.sql | wc -l
```
나온 수에서 2를 뺀다 — `COPY` 줄과 `\.` 줄이다. 이게 백업 시점의 행 수이고, D-1 의 덤프가 여기서 한 번 더 값을 한다.
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 15:0015:26 KST` 에 돈 두 번의 실행에서 나왔다(observed).
- (observed) 두 실행의 백업 크기 `396333 bytes` · `395375 bytes`, 시작 태그 `quay.io/keycloak/keycloak:26.7.0`, 마이그레이션 `210`, 세션 `4`(첫 실행)와 `3`(후속 실행), 업그레이드 후의 `ISPN000094` 줄과 판 `16.0.14`, 태그 변경 시각 `15:22:59` 와 롤아웃 완료 `15:24:26`, 새 파드의 `restarts=0` 과 나이 `10m`·`28s`, 로그의 `Keycloak 26.7.3`, 정방향 폴링 `200 응답: 87 회 / 비200 0`, 업그레이드 후 `마이그레이션 후: 210 (전: 210)`, 롤백의 `15:25:08``15:25:53` 과 `Keycloak 26.7.0` · 마이그레이션 210 · 세션 3, 롤백 폴링 `43 회 / 비200: 1` 과 그 1이 `000` 인 것, 역방향 시각 `15:02:20` 과 20초 간격 상태 여덟 줄, `1 changesets check sum` 두 줄, `ready 주소: [10.42.1.140]` 과 `desired/ready/updated: 2 / 1 / 1`, 실패한 기동 뒤에도 `210` 인 것.
- (observed) Grafana 화면 `d2-upgrade-window.png` 에 `cluster_size` 가 2 → 1 → 2 를 두 번 반복한 것.
- (unknown) `databasechangelog` 의 마지막 다섯 줄을 뽑는 쿼리, 레지스트리 태그 목록을 `tr`·`grep` 으로 자르는 줄, 덤프에서 행 수를 되찾는 `sed` 줄. 가이드가 전부 미검증으로 표시했다. `kubectl rollout undo` 도 이 실험은 쓰지 않았다.
- 비밀은 이 편에 나오지 않는다 — 이 실험이 다루는 값은 이미지 태그와 행 수라 옮길 비밀이 없다. 파드 이름·엔드포인트 주소·클러스터 멤버 이름은 식별자라 그대로 적었다.
- 가장 중요한 미검증이 첫 줄이다. 「행 수가 늘면 태그로 못 돌아온다」는 역방향(26.0)에서 관측한 실패를 근거로 한 추론이며(inferred), 실제로 행 수가 늘어난 뒤 되돌려 본 적은 없다. 26.7.x 사이에는 스키마 변경이 없어 이 실험대에서는 재현하지 못했다(unknown). 메이저 업그레이드를 할 때 이 실험을 다시 한다고 가이드는 적는다.
- 이 실험이 재지 않은 것 — 마이그레이션 도중에 죽으면 어떻게 되는지, 대규모 마이그레이션에 걸리는 시간. 데이터가 작아 순식간이라 잴 것이 없었다.
<!-- body:end -->
@@ -0,0 +1,541 @@
---
id: 186443e8-a32a-4a94-8609-845a4247d120
kind: SETUP
slug: reproduce-d3-secret-exposure
title: 카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/186443e8-a32a-4a94-8609-845a4247d120/edit"
pinnedVersions:
- name: k3s 저장소 암호화
version: Disabled
- name: 판 번호
version: SSOT D-3 절에 한 줄도 없다
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다
지워도 되는 카나리아 Secret 하나를 심고 API·노드 디스크·파드 안·접근 제어 네 경로에서 평문이 어디까지 나오는지 재는 절차다. 파괴적인 단계가 없고, 복구는 그 Secret 을 지우는 한 줄이다.
## 관계
- **볼륨 없는 영속화와 유예 없는 키 회전**
이 절차가 판정하는 것은 값이 아니라 경로다. 그 값들을 어떻게 갈아 끼우는지는 그쪽이 다룬다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
화면이 가려 준다고 감춰진 것은 아니라는 구별을, 지표 쪽에서 같은 모양으로 다룬다.
- **스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다**
거기서는 덤프를 어디에 두느냐를 물었고, 여기서는 노드 디스크 하나가 모든 비밀이라는 답이 나온다.
- **서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다**
비밀이 샜을 때 실제로 해야 하는 일이 삭제가 아니라 회전인 까닭을 그쪽이 다룬다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
같은 결론이 이 실험대의 cookie secret 에도 그대로 적용된다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 친다. k3s 서버의 저장 파일도 이 노드에 있어서 디스크를 보는 경로를 여기서 칠 수 있다. 게스트(`kc-lab-1`/`kc-lab-2`)의 `sudo` 는 무암호이고, 호스트와 다르다.
가이드의 전제는 「`kubectl``sudo` 로 쓴다」인데 본문의 `kubectl` 줄에는 `sudo` 가 없고 `k3s`·`ls`·`grep` 에만 붙어 있다. 아래는 본문의 형태를 그대로 옮긴다. **먼저 `sudo` 없이 치고, 권한 때문에 막히면 그때 앞에 `sudo` 를 붙인다.** 어느 쪽인지는 kubeconfig 를 어디에 뒀나가 가른다 — D-1 은 같은 전제를 옮기면서 「kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다」를 괄호로 달아 두었고, D-3 은 그 괄호가 없다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 주입 수단 | 카나리아 Secret 하나 — `d3-canary` |
| 되돌리는 수단 | `delete secret d3-canary` 한 줄 |
| 전 구간 | 약 15분 |
| 파괴적인 단계 | 없다. 다섯 편 중 유일하다 |
| `jq` | 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다 |
**이건 비밀을 화면에 띄우는 실험이다.** 몇 개의 명령은 비밀번호를 터미널에 그대로 찍는다. 그게 결론이라 피할 수 없지만, 그 값은 스크롤백·화면 공유·터미널 로그에 남는다. 가이드는 그래서 셋을 정해 두고 시작한다.
- 남의 진짜 비밀은 길이(`wc -c`)와 키 이름까지만 본다.
- 값을 찍어 봐야 하는 곳은 이 실험용으로 직접 만든 카나리아 Secret 을 쓴다. 지워도 되는 값이므로 찍어도 된다.
- 실측으로 실린 값들은 이 저장소의 매니페스트와 문서에 이미 적혀 있는 실험대 전용 값이고, 그래서 값 이름에 `change-me` 가 들어 있다.
**이 절차는 그 셋을 한 겹 더 지킨다.** 아래에서 API 로 뽑힌 네 값과 파드 안 환경변수 두 값은 키 이름과 길이까지만 적는다. 그리고 가이드가 `grep` 인자에 클라이언트 비밀 평문을 적어 둔 두 줄은 **카나리아 문자열로 바꿔** 실었다 — 명령의 모양은 같고 옮기면 안 되는 값만 빠졌다.
## 이 실험이 가르는 것
「비밀번호를 Secret 으로 옮겼습니다」는 리뷰에서 통과 도장을 받는 문장이라고 가이드는 적는다. 이 절차는 그 문장이 실제로 무엇을 막아 주는지를 네 경로로 나눠 판정한다. 가이드는 예측 칸을 넷 다 물음표로 비워 두고 시작한다.
| # | 경로 | 누가 쓰나 |
|---|---|---|
| ① | 쿠버네티스 API (`get secret`) | 클러스터에 접근하는 사람 |
| ② | 노드 디스크의 저장 파일 | 디스크·백업·스냅샷을 얻은 사람 |
| ③ | 파드 안의 프로세스 | `exec` 권한이 있는 사람, 크래시 덤프 |
| ④ | RBAC | 권한이 없는 주체 |
판정에 앞서 개념 둘을 가른다.
| | 목적 | 되돌리기 |
|---|---|---|
| 인코딩 (base64) | 바이너리를 텍스트로 안전하게 옮기기 | 키 없이 누구나 |
| 암호화 | 키 없이는 못 읽게 하기 | 키가 있어야 |
Secret 이 base64 를 쓰는 까닭은 감추려는 것이 아니라 YAML 에 임의 바이트를 담기 위해서다. 그런데 `kubectl describe` 가 값을 가려서 보여 주므로 「가려져 있구나」라는 인상이 남는다. 이 절차는 그 인상과 사실 사이의 거리를 잰다.
가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. `describe``14 bytes` 만 보여 주는 것, 같은 값이 한 줄로 평문이 되는 것, 저장소 암호화가 꺼져 있는 것, 노드 디스크의 저장 파일 안에 평문이 있는 것, 그 `grep``0` 을 돌려주는데도 안전하지 않은 것, 파드 안에서는 그냥 환경변수인 것, 접근 제어는 실제로 막는 것.
**다섯째가 이 편의 요점이다.** 같은 파일에 같은 명령을 걸었는데 키에 따라 `2``0` 이 나왔고, `0` 을 「없다」로 읽으면 틀린다는 것을 가이드가 따로 한 절로 적는다.
## 전제와 되돌리기
- 명령은 `kc-lab-1` 에서 친다. k3s 서버의 저장 파일도 이 노드에 있고, 그래서 ②를 여기서 칠 수 있다.
- 게스트의 `sudo` 는 무암호다. 호스트와 다르다.
- 네임스페이스는 `keycloak-lab` 이다.
- B-6(key 회전)와 B-7(쿠키 비밀 회전)을 이미 했다면 이 실험의 결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이라고 가이드는 적는다.
**파괴적인 단계가 없는 편이다.** 만드는 것은 카나리아 Secret 하나뿐이고 복구 절에서 지운다. 전 구간 약 15분. 되돌리기는 한 줄이다.
```bash label="[kc-lab-1] 되돌리기 한 줄"
kubectl -n keycloak-lab delete secret d3-canary
```
## 주입 전에 같은 명령으로 먼저 본다
**무엇이 있는지부터 본다.** 카나리아를 심기 전에 목록과 `describe` 화면을 봐 둬야, 심은 뒤의 `describe` 가 같은 화면이라는 것이 보인다.
```text
Secret 목록 → describe 가 감추는 화면 → 키 이름만 → 길이만
```
### 1. Secret 이 몇 개 있는가
**무엇을 보는가** — 이름과 키 개수.
```bash label="[kc-lab-1] Secret 목록을 본다"
kubectl -n keycloak-lab get secret
```
**어디를 보나** — 실측은 이렇다(observed, `01-base64-not-encryption.txt`).
```text
bff-secrets Opaque keys=1
keycloak-lab-secrets Opaque keys=2
oauth2-proxy-secrets Opaque keys=3
```
**이 값이 뜻하는 것** — 이 실험대는 스크립트로 정리해 찍었다(observed). 위 명령을 그대로 치면 `NAME` · `TYPE` · `DATA` · `AGE` 네 칸이 나오고, `DATA` 열이 실측 줄의 `keys=` 에 해당한다. `TYPE` 이 `Opaque` 인 것도 본다 — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는 뜻이지 감춘다는 뜻이 아니다.
### 2. describe 가 무엇을 감추는가
**무엇을 보는가** — 키 이름과 바이트 수.
```bash label="[kc-lab-1] describe 화면을 본다"
kubectl -n keycloak-lab describe secret bff-secrets
```
**어디를 보나** — 실측은 이렇다(observed, `01-base64-not-encryption.txt`).
```text
Type: Opaque
Data
====
KEYCLOAK_CLIENT_SECRET: 14 bytes
```
**이 값이 뜻하는 것** — 키 이름과 바이트 수만 나오고 값이 없다. 이 화면이 「Secret 은 감춰진다」는 인상의 출처다. `describe` 는 일부러 값을 안 찍는데, 그건 `describe` 라는 명령의 동작이지 저장이나 전송의 성질이 아니다. 이 구별이 이 편 전체의 축이다.
### 3. 남의 비밀은 키 이름과 길이까지만 본다
**무엇을 보는가** — 어떤 키가 들어 있는가. 값은 보지 않는다.
```bash label="[kc-lab-1] ① 키 이름만 뽑는다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
| tr ',' '\n' | grep -o '"[A-Z_]*"'
```
**어디를 보나** — 모양은 이렇다(observed).
```text
"KC_BOOTSTRAP_ADMIN_PASSWORD"
"POSTGRES_PASSWORD"
```
`jq` 가 없어서 `tr` 과 `grep` 으로 자른다. D-2 가 레지스트리 태그 목록을 자를 때 쓴 것과 같은 수법이고, `jq` 가 없다는 전제가 여기서도 형태를 정한다.
```bash label="[kc-lab-1] ② 값 대신 길이를 잰다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
```
**어디를 보나** — 모양은 이렇다(observed).
```text
22
```
**이 값이 뜻하는 것** — 숫자 하나가 나오고 값은 화면에 없다. 「Secret 이 제대로 들어갔는가」를 확인하는 데는 길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개 「비었는가 아닌가」이지 값 자체가 아니다.
**문제가 생기면** — `wc -c` 는 개행까지 세므로 `base64 -d` 결과에 개행이 없으면 실제 길이와 같다. 값이 비었으면 `0` 이 나오고, `0` 은 「Secret 은 있는데 그 키가 비었다」는 뜻이며 배포 실패의 흔한 원인이라고 가이드는 적는다.
## 주입
여기부터 상태가 바뀐다. 바뀌는 것은 Secret 하나다.
### 1. 카나리아 Secret 을 심는다
**목적** — 저장 파일 안을 `grep` 할 때 쓸, 찾아도 아무 피해가 없는 문자열을 하나 만든다.
**왜 카나리아를 쓰는지가 먼저다.** 관찰 절에서 저장 파일 안을 `grep` 해야 하는데, 그러려면 찾을 문자열을 알고 있어야 한다. 진짜 비밀번호를 `grep` 인자로 쓰면 그 값이 셸 히스토리와 프로세스 목록(`ps` 로 다른 사용자에게도 보인다)에 남는다. 실험 대상이 값 자체가 아니라 경로이므로 이렇게 해도 결론은 같다.
① 카나리아를 만든다.
```bash label="[kc-lab-1] ① 카나리아 Secret 을 만든다"
kubectl -n keycloak-lab create secret generic d3-canary \
--from-literal=CANARY=d3-canary-zq7v-do-not-use
```
**예상 결과** — 모양은 이렇다(observed).
```text
secret/d3-canary created
```
**왜 필요한가** — 이 값은 아무 데도 쓰이지 않는다. 어떤 파드도 참조하지 않으므로 지워도 아무것도 안 깨진다. 값에 `do-not-use` 를 넣어 둔 까닭은 나중에 저장 파일 어딘가에서 이 문자열을 다시 만났을 때 무엇인지 알아보기 위해서다.
**문제가 생기면** — 이미 있으면 `AlreadyExists` 가 나온다. 지우고 다시 만든다.
## 주입 검증
```bash label="[kc-lab-1] 카나리아가 심겼는지 본다"
kubectl -n keycloak-lab get secret d3-canary
kubectl -n keycloak-lab describe secret d3-canary
```
모양은 이렇다(observed).
```text
Data
====
CANARY: 25 bytes
```
**이 화면은 이 실험대가 본 적이 없다**(unknown) — 카나리아를 심지 않고 실제 값으로 쟀기 때문이다. `--from-literal` 이 개행을 붙이지 않으므로 `25` 가 `d3-canary-zq7v-do-not-use` 의 글자 수와 그대로 맞는다. 가이드에 `26` 으로 적혀 있던 것을 고쳤다. 가이드 자신이 적어 둔 `--from-literal` 값의 글자 수와도 맞지 않는 수였다.
**여기서도 `describe` 는 바이트 수만 준다. 주입 전에 본 화면과 같다.** 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다 — 저장 파일에서 이 문자열을 찾았을 때 그것이 무엇인지 아는 사람이 당신 하나이기 때문이다.
## 관찰
네 경로를 하나씩 연다. ①은 API, ②는 노드 디스크, ③은 파드 안, ④는 RBAC(Role-Based Access Control, 역할 기반 접근 제어)다.
### 1. ① API — 한 줄로 읽힌다
값을 아는 카나리아로 먼저 해 본다.
```bash label="[kc-lab-1] ① 카나리아를 API 로 뽑아 본다"
kubectl -n keycloak-lab get secret d3-canary \
-o jsonpath='{.data.CANARY}' | base64 -d; echo
```
모양은 이렇다(observed).
```text
d3-canary-zq7v-do-not-use
```
주입 절에서 심은 값이 그대로 나온다. 같은 명령이 실제 비밀에도 그대로 듣고, 원래 실행이 네 개를 뽑은 결과가 증거 파일에 있다(observed, `01-base64-not-encryption.txt`). **값은 옮기지 않는다** — 네 줄 전부 `<Secret>/<키> = <평문>` 꼴로 나왔고, 값 자리에 있던 것은 이름에 `change-me` 가 들어간 실험대 전용 문자열이다. 원문은 증거 파일에 둔다.
```text
keycloak-lab-secrets/POSTGRES_PASSWORD = <평문 22자>
keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD = <평문>
bff-secrets/KEYCLOAK_CLIENT_SECRET = <평문 14자>
oauth2-proxy-secrets/COOKIE_SECRET_A = <평문>
```
실험대의 모든 비밀이 명령 네 줄로 나온다. `describe` 가 `14 bytes` 라고 했던 그 키의 값이 정확히 14자다 — 같은 값을 명령 둘이 다르게 보여 주고 있었고, 감춘 쪽은 `describe` 뿐이다.
**①은 막지 않는다.** base64 는 인코딩이고 `base64 -d` 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 누가 이 명령을 칠 수 있는가이며, 그건 ④로 넘어가는 물음이다.
이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다고 가이드는 적는다. 카나리아로 한 번 확인했으면 기제는 같고, 진짜 비밀은 앞에서 한 길이 확인으로 충분하다.
### 2. ② 저장소 — 노드 디스크에 평문이 있다
암호화 설정부터 본다.
```bash label="[kc-lab-1] ① 저장소 암호화 상태를 본다"
sudo k3s secrets-encrypt status
```
실측은 이렇다(observed, `02-at-rest.txt`).
```text
Encryption Status: Disabled, no configuration file found
```
`Disabled`, 그리고 `no configuration file found` 를 본다. 설정 파일이 아예 없다 — 껐다기보다 켠 적이 없다는 뜻이고, 이게 기본값이다.
```bash label="[kc-lab-1] ② 저장 파일 셋을 본다"
sudo ls -l /var/lib/rancher/k3s/server/db/
```
실측은 이렇다(observed, `02-at-rest.txt`).
```text
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 label="[kc-lab-1] ③ 저장 파일에서 카나리아를 찾는다"
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
```
이 실험대는 카나리아 대신 실제 값으로 쟀다(observed). 위의 카나리아 형태는 가이드가 미검증으로 표시했다(unknown). 실제로 나온 결과가 이것이다(observed, `02-at-rest.txt`).
```text
=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
state.db 안의 평문 일치: 2
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
client secret 평문 등장 횟수: 0
```
**두 줄의 값이 다르다. `2` 와 `0` 이다.** 같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다. 가이드가 이 절에서 제일 중요하다고 적은 문장이 그다음에 온다.
> `grep` 이 `0` 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에 이 형태로는 못 찾았다」이다.
`2` 가 나온 순간 ②의 답은 이미 정해졌다 — 저장 파일에 평문이 있다. `0` 이 나온 키를 두고 「그건 안전한가 보다」라고 읽으면, 같은 파일에 평문이 들어 있는 것을 이미 본 뒤에 그러는 셈이다.
`0` 이 나왔을 때 다음에 볼 곳을 가이드가 적어 두긴 했는데, 이 실험은 원인을 가리지 않았다(unknown). 아래 두 줄과 표가 전부 미검증이다.
**카나리아로 쳐서 두 줄 다 `0` 이 나와도 여기서 멈추지 않는다.** 방금 만든 값이라 아직 `-wal` 에만 있거나 둘 다에 안 내려갔을 수 있고, 어느 쪽인지 가르는 절차를 이 실험이 밟지 않았다(unknown). ②의 판정은 당신 화면의 숫자가 아니라 위 실측의 `2` 가 이미 냈다. 아래 표로 한 번 더 보고, 숫자가 무엇이든 ③으로 넘어간다.
**가이드는 이 두 줄의 인자에 클라이언트 비밀 평문을 적어 두었다. 값은 옮기지 않는다** — 그리고 가이드 자신이 주입 절에서 「진짜 비밀번호를 `grep` 인자로 쓰면 셸 히스토리와 `ps` 에 남는다」고 적었으므로, 아래에는 카나리아 문자열을 넣었다.
```bash label="[kc-lab-1] ④ -wal 과 strings 로 한 번 더 본다 (미검증)"
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
sudo strings /var/lib/rancher/k3s/server/db/state.db | grep -c 'd3-canary-zq7v-do-not-use'
```
| 왜 안 나올 수 있나 | 확인 |
|---|---|
| 아직 `-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 재암호화를 수반하고, 이 실험대에서 시험하지 않았다(unknown).
### 3. ③ 파드 안 — 평범한 환경변수다
어느 파드를 볼지 먼저 정한다.
```bash label="[kc-lab-1] ① BFF 파드를 본다"
kubectl -n keycloak-lab get pods -l app=bff
```
이 실험대는 파드 이름을 직접 지정했다(observed). 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown).
**치기 전에 — 이 줄은 값을 화면에 찍는다.** 나오는 것은 카나리아가 아니라 `KEYCLOAK_CLIENT_SECRET` 과 `BFF_DB_PASSWORD` 의 평문이다. 바로 아래 실측을 `<평문 14자>` 로 가린 것은 이 문서이지 당신의 터미널이 아니다. 찍힌 값은 스크롤백과 셸 히스토리에 남고, 화면을 공유 중이면 보는 사람 모두에게 간다. 이 편이 「읽기 전에」에서 세운 「남의 진짜 비밀은 길이와 키 이름까지만 본다」를 이 줄 하나가 벗어나는데, 같은 결론을 값 없이 내는 명령은 가이드에 없다(unknown). 운영 클러스터에서는 치지 않는다. 실험대에서 쳤으면 복구 절의 `history` 확인까지 마치고, 운영 값을 찍었으면 회전(B-6·B-7)으로 이어 간다.
```bash label="[kc-lab-1] ② 파드 안의 환경변수를 본다 (미검증 · 평문이 화면에 찍힌다)"
kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"'
```
실측은 이렇다(observed, `02-at-rest.txt`). **값은 옮기지 않는다** — 두 줄 다 `<환경변수>=<평문>` 꼴이고, 오른쪽에 있던 것이 ①에서 API 로 뽑은 바로 그 값이다.
```text
KEYCLOAK_CLIENT_SECRET=<평문 14자>
BFF_DB_PASSWORD=<평문 22자>
```
`env` 한 번이면 나온다. 그리고 클라이언트 비밀은 ①에서 API 로 뽑은 값과 같다 — 두 경로가 같은 평문에 닿는다.
`exec` 이 `deploy/bff` 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라 친다.
```bash label="[kc-lab-1] ③ Running 인 파드 이름을 고른다"
kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'; echo
```
**이 줄은 이름을 찍기만 한다.** 화면에 나온 `bff-...` 를 ②의 `deploy/bff` 자리에 그대로 넣어 다시 친다 — `exec` 다음의 대상만 바뀌고 `-- sh -c '...'` 부터는 같다. 가이드는 그 이름을 받아 치는 줄까지는 적지 않았다.
같은 파드 안의 다른 프로세스도 본다. 이게 환경변수의 진짜 성질이다. 아래도 가이드가 미검증으로 표시한 형태다(unknown).
**이 줄도 값을 화면에 찍는다.** ②에서 본 것과 같은 평문이 같은 자국을 남긴다. 여기서 확인하려는 것은 「같은 값이 또 나오는가」뿐이므로, 화면을 공유 중이거나 운영 클러스터에 붙어 있으면 치지 않고 ②의 결과로 판정한다.
```bash label="[kc-lab-1] ④ 다른 프로세스의 환경변수를 읽는다 (미검증 · 평문이 화면에 찍힌다)"
kubectl -n keycloak-lab exec deploy/bff -- \
sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret'
```
같은 값이 나오는가를 본다. `/proc/<pid>/environ` 은 그 프로세스의 환경변수를 그대로 담고 있고, 같은 사용자 id 로 도는 아무 프로세스나 읽는다.
| 새는 경로 | |
|---|---|
| `kubectl exec` 권한이 있는 사람 | 바로 본다 |
| 같은 파드의 다른 프로세스 | `/proc/<pid>/environ` |
| 크래시 덤프 · 오류 리포트 | 환경변수를 함께 담는 도구가 많다 |
| 자식 프로세스 | 상속된다 |
볼륨으로 마운트하면 이 중 몇 가지가 줄어든다 — 파일 권한으로 제한할 수 있고, 환경변수 덤프에 안 들어간다.
```yaml
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
```
줄어드는 것이지 없어지지는 않는다. `exec` 권한이 있으면 파일도 읽는다.
### 4. ④ RBAC — 유일하게 막는다
```bash label="[kc-lab-1] ① 기본 서비스계정이 Secret 을 읽을 수 있는지 묻는다"
kubectl auth can-i get secrets -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
```
실측은 이렇다(observed, `02-at-rest.txt`).
```text
default SA: no
```
`no` 한 단어다. 기본 서비스계정은 Secret 을 못 읽는데, 명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문이다. RBAC 은 기본이 거부이고 Role 을 붙여야 할 수 있게 된다.
어떤 권한이 있는지 통째로 보는 형태도 가이드에 있고, 미검증이다(unknown).
```bash label="[kc-lab-1] ② 권한 목록과 Role 을 본다 (미검증)"
kubectl auth can-i --list -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
kubectl -n keycloak-lab get role,rolebinding
```
네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다. 그러므로 실질적인 방어선은 「누가 `get secrets` 를 할 수 있는가」이며, 관리자 권한을 가진 사람에게는 아무 방어가 없다. A-0 의 관측 스택에서 `nodes/proxy` 서브리소스를 따로 줘야 했던 것처럼 Secret 접근도 리소스 단위로 나눌 수 있다고 가이드는 덧붙인다.
### 5. 네 경로를 한 표로 모은다
| # | 경로 | 감춰지는가 | 무엇이 뚫나 |
|---|---|---|---|
| ① | `get -o jsonpath \| base64 -d` | 아니다 | 클러스터 접근 권한 |
| — | `describe secret` | 값을 숨긴다 | 그래서 안전하다고 착각한다 |
| ② | 저장 파일(`state.db`) | 아니다. 암호화 꺼짐 | 노드 디스크·백업·스냅샷 |
| ③ | 파드 안 | 아니다. 평범한 환경변수 | `exec` · `/proc` · 크래시 덤프 |
| ④ | RBAC | 막는다 | 관리자 권한 |
「Secret 이니까 안전하다」는 네 가지 중 하나만 맞다. 그리고 ②·③ 은 쿠버네티스 API 를 한 번도 거치지 않고 평문에 닿는다.
무엇을 해야 하는가를 가이드가 다섯 단계로 적고, 이 실험대는 그중 아무것도 하고 있지 않다고 같은 표에 적는다.
| 단계 | 얻는 것 | 이 실험대 |
|---|---|---|
| ① 매니페스트에서 값을 빼고 `.example` 만 커밋 | git 유출을 막는다 | 안 함 |
| ② k3s `--secrets-encryption` 활성화 | 노드 디스크 유출을 막는다 | 안 함 (unknown) |
| ③ 환경변수 대신 볼륨 마운트 | 프로세스·덤프 유출을 줄인다 | 안 함 |
| ④ SealedSecret / 외부 KMS | 매니페스트에 암호문만 남는다 | 안 함 |
| ⑤ RBAC 최소화 | 유일하게 이미 동작하는 방어선을 좁힌다 | 기본값 그대로 |
실험 목적으로는 의도적이지만 그 사실을 기록해 두지 않으면 그대로 운영에 옮겨간다고 가이드는 적는다. 값 이름에 `change-me` 를 넣어 둔 것이 그 최소한의 표시다.
## 복구와 원상복구 확인표
### 1. 카나리아를 지운다
**목적** — 주입 전에 본 목록으로 되돌린다.
① 지우고 목록을 다시 본다.
```bash label="[kc-lab-1] ① 카나리아를 지우고 목록을 본다"
kubectl -n keycloak-lab delete secret d3-canary
kubectl -n keycloak-lab get secret
```
**예상 결과** — 모양은 이렇다(observed).
```text
secret "d3-canary" deleted
```
목록이 세 개로 돌아왔는가를 본다.
**왜 필요한가** — 어떤 파드도 이 Secret 을 참조하지 않으므로 지워도 아무것도 안 깨진다.
**문제가 생기면** — `NotFound` 가 나오면 이미 지워졌다.
### 2. 지웠다고 파일에서 없어지지는 않는다
아래는 미검증이고, 이 실험은 삭제 후를 재지 않았다(unknown).
```bash label="[kc-lab-1] 삭제 뒤에 저장 파일을 다시 본다 (미검증)"
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 의 평문이 아직 파일에 있는 것이다. 어느 쪽이든 관찰 절의 결론은 안 바뀐다 — 판정은 이미 `2` 에서 났다.
데이터베이스 파일은 지운 행의 공간을 즉시 0으로 덮어쓰지 않는다. 「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는 다른 사건이고, 비밀이 유출됐을 때 실제로 해야 하는 일이 삭제가 아니라 회전(rotation)인 까닭이 여기 있다 — B-6·B-7 의 주제다.
### 3. 네 항목을 대조한다
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 카나리아 | `kubectl -n keycloak-lab get secret d3-canary` | `NotFound` |
| Secret 목록 | `kubectl -n keycloak-lab get secret` | 세 개 |
| 파드 | `kubectl -n keycloak-lab get pods` | 전부 `Running` (아무것도 안 건드렸다) |
| 터미널 | `history \| tail -40` | 비밀번호가 찍힌 줄이 어디까지 남았는지 본다 |
**이 실험의 진짜 뒷정리는 스크롤백이다.** ①을 실제 비밀로 쳤다면 그 값이 터미널 버퍼와 셸 히스토리에 있다. 실험대 값이라 지금은 상관없지만, 같은 절차를 운영에서 하면 그게 유출 경로가 된다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `grep` 이 `0` 인데 안전하다고 읽힌다 | `0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」 | `-wal` 과 `strings` 로 한 번 더 |
| `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` 으로 이름을 고른다 |
| `auth can-i` 가 `yes` 라고 한다 | 그 서비스계정에 Role 이 붙어 있다 | `get rolebinding -o wide` 로 누가 줬는지 본다 |
| 값이 `0 bytes` 로 나온다 | Secret 은 있는데 키가 비었다 | `describe` 의 바이트 수를 본다 — 배포 실패의 흔한 원인 |
| 비밀번호를 화면에 찍어 버렸다 | ①을 실제 값으로 쳤다 | 스크롤백·히스토리를 지우고, 운영이면 회전한다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 15:0515:06 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) Secret 세 개의 목록과 `keys=1` · `keys=2` · `keys=3`, `KEYCLOAK_CLIENT_SECRET: 14 bytes`, 카나리아의 `CANARY: 25 bytes`, `Encryption Status: Disabled, no configuration file found`, `db/` 의 파일 셋과 크기(`13078528` · `32768` · `10769712`), 저장 파일에서 평문 일치 `2` 와 같은 명령의 다른 키 `0`, 파드 안 `env` 두 줄, `default SA: no`.
- **판 번호가 이 편에 한 줄도 없다.** SSOT D층 머리말에는 B층 같은 버전 표가 없고, D-3 을 친 시각 `15:0515:06` 이 D-2 의 첫 실행(`15:0015:10`, 역방향 `26.0` 으로 `keycloak-1` 이 CrashLoop)과 겹쳐 그때 어느 판이 돌고 있었는지가 정해지지 않는다. 그래서 다른 편의 값을 끌어오지 않았다. 고정한 버전에 적은 것은 이 절차가 성립한 구성인 「저장소 암호화 꺼짐」 하나다.
- **비밀은 길이·존재·키 이름만 적는다** — API 로 뽑힌 네 값과 파드 안 환경변수 두 값은 평문이 그대로 찍힌 줄이라 이 절차로 옮기지 않았다. 키 이름과 `14 bytes` · `22` 라는 길이, 그리고 값 이름에 `change-me` 가 들어 있다는 모양까지가 옮긴 전부다. 원문은 증거 파일에 그대로 있다.
- **카나리아 값은 그대로 적었다** — `d3-canary-zq7v-do-not-use` 는 이 실험이 `grep` 인자로 쓰려고 직접 만든 문자열이고 복구 절에서 지운다. 값을 알아야 명령이 성립하므로 명령과 함께 남겼다. 찾아도 아무 피해가 없다는 것이 이 값의 목적이다. 그리고 가이드가 `0` 이 나온 키를 다시 찾을 때 쓴 두 줄에는 클라이언트 비밀 평문이 인자로 적혀 있었는데, 그 인자를 카나리아 문자열로 바꿔 적었다 — 명령의 모양은 같고 옮기면 안 되는 값만 빠졌다.
- (unknown) 카나리아를 저장 파일에서 찾는 두 줄, `0` 이 나왔을 때 `-wal`·`strings` 로 다시 보는 두 줄, `exec deploy/bff` 형태, `/proc/1/environ` 을 읽는 줄, `auth can-i --list`, 삭제 뒤에 다시 `grep` 하는 두 줄. 가이드가 전부 미검증으로 표시했다. 이 실험대는 카나리아 대신 실제 값으로 쟀고, 삭제 후는 재지 않았다.
- 이 실험이 확인하지 않은 것 — k3s `--secrets-encryption` 을 켠 뒤의 상태. 켜는 것은 서버 재시작과 기존 Secret 재암호화를 수반하고, 여기서는 시험하지 않았다. `0` 이 나온 키의 원인도 가리지 않았다. 볼륨 마운트·SealedSecret·외부 KMS 도 전부 안 했다.
<!-- body:end -->
@@ -0,0 +1,632 @@
---
id: 4f32b469-185a-4dea-8eba-599864a3b476
kind: SETUP
slug: reproduce-d4a-deploy-hook
title: deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다
topic: operations-that-report-success
topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/4f32b469-185a-4dea-8eba-599864a3b476/edit"
pinnedVersions:
- name: certbot
version: 5.7.0
source:
- final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다
certbot 의 `deploy/` 훅에 두 줄짜리 파일 하나를 넣고, 갱신 뒤 nginx 워커가 사람 손 없이 갈리는지 확인하는 절차다. D-4 가 끝나 있어야 성립하고 인증서를 한 장 더 쓴다.
## 관계
- **deploy 훅 하나가 그 공백을 1~2초로 줄였다**
이 절차가 만드는 워커 교체와 1~2초를 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
- **새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다**
이 절차가 고치는 결함을 그 기록이 잰다. 훅이 없을 때의 값이 거기 있다.
- **reload 를 사람이 아니라 deploy 훅이 부르게 한다**
이 절차가 넣는 파일이 그 결정의 내용이고, 여기서 나온 두 값이 그 결정의 근거다.
- **적용됐는지는 로그 문구가 아니라 상태로 판정한다**
certbot 이 성공한 훅에도 `ran with error output` 을 찍는 것을 여기서 만난다. 판정은 워커 PID 로 한다.
- **두 시계에서 온 값을 빼지 않는다**
여기서는 106초를 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나온다.
- **갱신 타이머가 실제 갱신에서도 도는가**
이 절차는 강제 갱신으로만 훅을 시험한다. 타이머가 스스로 갱신하는 경로는 약 59일 뒤에야 조건이 성립한다.
- **인증서를 강제로 갱신하고 밖에서 보이는 일련번호가 언제 바뀌는지 잰다**
먼저 해 둬야 하는 편이다. 판정 기준과 시계 왜곡 값을 거기서 재 두고, 이 절차는 그 값을 그대로 쓴다. 주입 방향도 되돌리기도 반대라 절차를 겹쳐 적지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기계가 둘이다. 밖에서 보는 `openssl``[dev]` 에서 치고, 주입은 전부 `[test-server]` 쪽이라 사람이 비밀번호를 친다.
| 무엇 | 값 |
|---|---|
| 관찰하는 기계 | 개발 머신 `dev`. 시계가 외부 기준과 맞는다 |
| 주입하는 기계 | 호스트 `test-server`. 시계가 **106초** 빠르다 |
| 바꾸는 것 | 파일 하나, 두 줄. `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh` |
| certbot | `5.7.0`. **nginx 플러그인은 없다** |
| 인증서 | `--force-renewal` 을 또 한 번 친다. 주당 중복 인증서 5장 한도를 두 장 쓴 셈이 된다 |
| 원래 실행 | 2026-09-04 `12:27` UTC(실제) |
**시각 표기 규약은 D-4 와 같고 여기서는 훨씬 무겁다.** 이 절차는 1~2초를 재고, 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.
| 표기 | 뜻 |
|---|---|
| `12:27:49 (실제)` | 보정한 값. 외부 기준과 일치 |
| `21:29:36 KST (ts)` | test-server 시계. 106초 빠르다 |
| `12:29:05 (dev)` | 개발 머신 시계. 보정 불필요 |
## 이 실험이 가르는 것
D-4 는 결함을 찾고 처방을 적어 두고 검증하지 않았다.
| D-4 가 남긴 항목 | 상태 |
|---|---|
| deploy 훅을 넣으면 자동 반영되는가 | 미측정. 훅은 아직 넣지 않았다 |
처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것은 이 실험대가 스물세 번 경계해 온 실수라고 가이드는 적는다. 그래서 별도 실험으로 분리했다.
판정할 것은 셋이다.
| # | 질문 | 무엇으로 가르나 |
|---|---|---|
| ① | 훅이 실행되는가 | certbot 출력 |
| ② | nginx 가 정말 reload 되는가 | 워커 PID (문구가 아니라) |
| ③ | 얼마나 빠른가 | SCT ↔ 보정한 훅 시각 |
②가 이 편의 방법이고 ③이 이 편에서 가장 까다롭다. 판정을 문구로 하면 certbot 이 찍는 `ran with error output` 에 걸려 성공을 실패로 읽고, 시각을 보정하지 않으면 훅이 발급보다 먼저 돈 것이 되어 물리적으로 불가능한 값이 나온다.
절차를 끝까지 밟으면 훅 디렉터리가 비어 있는 데서 파일 하나를 넣는 것, certbot 이 `ran with error output` 이라고 찍는데 실패가 아닌 것, 마스터는 그대로고 워커만 자동으로 갈리는 것, 서빙 인증서가 곧바로 바뀌는 것, 발급에서 서빙까지 1~2초인 것, 보정하지 않으면 훅이 발급보다 `107초` 뒤에 돈 것으로 나오는 것, `notBefore` 가 발급 시각이 아닌 것을 자기 화면에서 보게 된다.
**무중단인지는 이 편이 재지 않는다.** 폴링과 전송 중 요청 감시는 D-4 에 있고, 거기서 잰 `8856건``845361` 바이트는 사람이 친 `nginx -s reload` 를 잰 값이다. 이 편이 재는 것은 훅이 거는 reload 가 실제로 일어나는가와 그 속도다.
## 전제와 되돌리기
- **D-4 를 먼저 한다.** 특히 두 가지가 없으면 이 절차는 성립하지 않는다 — 「reload 판정은 워커 PID 로 한다」는 기준, 그리고 두 기계 시계의 왜곡을 미리 재 둔 값.
- 관찰은 dev 에서, 주입은 `test-server` 에서 사람이 친다.
- 호스트의 `sudo` 는 비밀번호를 요구한다. 이 절차의 주입은 전부 그쪽이다.
- 이 호스트의 certbot 은 `5.7.0` 이고 nginx 플러그인은 없다.
**★ 인증서를 한 장 더 쓴다.** `certbot renew --force-renewal` 을 또 한 번 치므로, D-4 에서 한 번 썼다면 이번이 두 번째이고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장 쓴 셈이 된다. 세어 두고, 절차만 확인하려면 `--dry-run` 을 먼저 쓴다.
**되돌리기는 한 줄인데, 되돌리지 않는 편이 낫다.** 훅은 결함을 고치는 파일이라 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다.
```bash label="[test-server] 훅을 지운다 — 지우면 D-4 의 상태로 돌아간다"
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
```
## 주입 전에 같은 명령으로 먼저 본다
네 칸이고, 마지막 칸이 이 편의 답을 지킨다. D-4 의 여덟 칸을 다시 밟지 않는다 — 체인·이름 셋·타이머는 그쪽에서 이미 봤다.
```text
워커 PID → 서빙 인증서와 SCT → 훅 디렉터리가 비었나 → ★ 시계 왜곡
```
### 1. 출발점 워커 PID 를 적어 둔다
**무엇을 보는가** — 마스터 PID(Process ID, 프로세스 번호)와 워커 PID 두 숫자, 그리고 워커의 `lstart`.
```bash label="[test-server] nginx 프로세스 두 줄을 본다"
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
```
**어디를 보나** — 실측은 이렇다(observed, `01-hook-verified.txt`).
```text
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 한 것
```
**이 값이 뜻하는 것** — 이 세 값을 적어 둔다. 관찰 절의 판정이 이 값과의 비교다.
워커 `28829` 는 D-4 에서 사람이 `nginx -s reload` 를 쳐서 생겼다. 마스터는 여전히 `585`, 어제 `19:00:39` 에 뜬 그대로다. 마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명이라는 것을 D-4 에서 확인했고, 이 절차는 그 기준을 그대로 쓴다. **그러니까 출발점 자체가 사람이 건 reload 의 결과이고, 이 절차가 재려는 것은 훅이 거는 reload 다.**
### 2. 서빙 인증서와 SCT 를 적어 둔다
**무엇을 보는가** — 지금 밖으로 나가는 인증서의 일련번호와, 발급 시각의 외부 기준.
```bash label="[dev] ① 일련번호와 유효기간을 본다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates
```
```bash label="[dev] ② 인증서 안의 SCT 를 본다"
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
```
**어디를 보나** — `serial` 을 적어 둔다. 관찰 절에서 이 값이 바뀐다. `Timestamp` 두 줄은 CT(Certificate Transparency, 발급 사실을 공개 로그에 남기는 구조) 로그가 자기 시계로 서명한 시각이다.
**이 값이 뜻하는 것** — SCT(Signed Certificate Timestamp, CT 로그가 인증서에 박아 주는 서명된 시각)는 이 실험대의 두 기계와 무관한 제3의 기준이라, 관찰 절에서 시계 보정의 심판이 된다.
### 3. 훅 디렉터리가 비어 있는지 본다
**무엇을 보는가** — `deploy/` 안에 무엇이 있는지. root 전용이라 `sudo` 가 필요하다.
```bash label="[test-server] deploy 디렉터리를 본다"
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
```
**어디를 보나** — 실측은 이렇다(observed, `d4-certificate-renewal/12-certbot-state.txt`).
```text
/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 에서 실제로 걸렸던 함정이다.
### 4. 시계 왜곡을 지금 잰다
**목적** — 이 절차의 답은 1~2초인데 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다. 왜곡은 사후에 되짚을 수 없다.
**1.** SSH(Secure Shell, 원격 셸 접속) 왕복 직전·직후의 시각과 저쪽 시각을 세 번 찍는다.
```bash label="[dev] ① 세 번 잰다"
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` 의 차이를 눈으로 뺀다. 그리고 세 번의 값이 서로 비슷한가를 본다 — 흔들리면 네트워크 지연이 섞였고, 안정적이면 진짜 왜곡이다.
**2.** 어느 쪽이 맞는지는 외부 기준으로 가른다.
```bash label="[dev] ② 이쪽 시각"
date -u
```
```bash label="[dev] ③ 외부 기준 둘"
curl -sI https://www.google.com | grep -i '^date:'
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
```
```bash label="[test-server] ④ 저쪽 시각과 NTP 동기 여부"
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'
```
**예상 결과** — 실측은 이렇다(observed, `01-hook-verified.txt`).
```text
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` 로 흔들리지 않았다는 것도 같이 본다.
```text
실제 시각 = test-server 시계 106초
```
**왜 필요한가** — Let's Encrypt 의 `Date:` 까지 보는 까닭은, 이 절차가 재는 사건의 한쪽 끝이 그쪽의 발급이기 때문이다. 그 기준과 dev 가 일치한다는 것을 확인해 두면 관찰 절의 비교가 같은 시간축 위에서 성립한다.
**문제가 생기면** — 세 번의 값이 흔들리면 회선이 조용할 때 다시 잰다.
## 주입
바꾸는 것은 파일 하나, 두 줄이다. 어느 디렉터리에 넣는가가 먼저 정해져야 한다.
| 디렉터리 | 언제 실행되나 |
|---|---|
| `pre/` | 갱신 시도 전 |
| `deploy/` | 실제로 갱신된 인증서가 있을 때만 |
| `post/` | 갱신 여부와 무관하게 매번 |
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 갱신이 없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만 갈아치운다. `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘겨줄 때, 즉 실제로 갱신했을 때만 돈다. 없거나 틀리면 D-4 가 측정한 그대로 갱신은 성공하고 서빙은 안 바뀌며, 그 상태로 타이머는 `SUCCESS` 를 찍는다.
**`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` 다(observed). 설정이 깨진 채 `restart` 를 걸면 10초 안에 5번 실패하고 systemd 가 포기한다 — nginx 가 내려간 채로 멈춘다.
### 5. 훅 파일을 만든다
**목적** — 사람이 비밀번호를 치며 실행할 명령을 짧게 만들려고, 파일 내용은 `sudo` 가 필요 없는 곳에서 미리 만들어 둔다.
**1.** 호스트에 붙는다.
```bash label="[test-server] ① 호스트 셸로 들어간다"
ssh test-server
```
**2.** 호스트의 셸에서 편집기로 연다.
```bash label="[test-server] ② 편집기로 연다"
nano /tmp/reload-nginx.sh
```
```sh
#!/bin/sh
nginx -t && nginx -s reload
```
**예상 결과** — 두 줄이 맞게 들어갔는가를 본다. `#!/bin/sh` 가 첫 줄이어야 한다.
```text
#!/bin/sh
nginx -t && nginx -s reload
```
**왜 필요한가** — 훅은 읽고 고칠 파일이지 한 번 찍고 마는 출력이 아니다. 파일을 열면 이미 무엇이 있는지 보이고, 같은 절차를 두 번 밟았을 때 `>>` 로 잘못 쳐서 줄이 두 번 들어가는 사고도 안 난다.
`/tmp` 를 여기서 쓰는 것은 괜찮은데, 이건 당신의 대화형 셸이 쓰는 `/tmp` 이기 때문이다. 다만 `certbot-renew.service` 는 `PrivateTmp=true`(실측(호스트), observed)라 그 서비스가 보는 `/tmp` 은 다른 곳이다. 훅이 나중에 `/tmp` 에 로그를 남기도록 만들면 타이머가 돌렸을 때 그 파일을 밖에서 찾을 수 없다(unknown — 이 실험은 훅에 로그를 넣지 않았다). 훅의 로그는 `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다.
**3.** 호스트 셸에서 나온다. **다음 절이 다시 `ssh` 로 들어가므로 여기서 나오지 않으면 test-server 안에서 test-server 로 또 붙게 된다.**
```bash label="[test-server] ③ 호스트 셸에서 나온다"
exit
```
**이 실험대는 셸로 파일을 만들었다**(observed). 편집기로 여는 형태는 이 형태로 실행하지 않았다(unknown). **아래 두 줄은 호스트 셸이 아니라 dev 머신에서 친 것이다** — 위 ①로 들어갔다면 이 형태는 쓰지 않는다.
```bash label="[dev 머신] 이 실험대가 실제로 친 형태 (observed)"
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` 가 아니면 certbot 이 훅을 실행하지 못한다. 파일을 다시 연다.
### 6. 훅을 설치한다
**목적** — `deploy/` 에 실행 권한과 함께 넣는다. 여기부터 사람이 비밀번호를 친다.
**1.** tty 를 붙여 호스트에 붙는다.
```bash label="[test-server] ① tty 를 붙여 들어간다"
ssh -t test-server
```
**2.** 호스트의 셸에서 설치한다.
```bash label="[test-server] ② 실행 권한과 함께 설치한다"
sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
```
**예상 결과** — 아무것도 안 나오면 성공이다.
**왜 필요한가** — `install -m755` 가 복사와 권한 설정을 한 번에 한다. `x` 비트가 없으면 certbot 이 훅을 그냥 건너뛴다.
**3.** 호스트 셸에서 나온다. 바로 아래 「이 실험대가 실제로 친 형태」가 dev 머신에서 치는 줄이라 한 번 나와야 한다. 그다음 §7 은 다시 호스트 셸 안에서 친다.
```bash label="[test-server] ③ 호스트 셸에서 나온다"
exit
```
**이 실험대는 설치와 강제 갱신을 한 줄로 쳤다**(observed). 사람이 비밀번호를 한 번만 치게 하려고 그렇게 쳤다. **아래는 호스트 셸이 아니라 dev 머신에서 친 것이다** — 위 ①로 들어갔다면 이 형태는 쓰지 않는다.
```bash label="[dev 머신] 이 실험대가 실제로 친 형태 (observed)"
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"'
```
읽기는 어렵다고 가이드가 스스로 적는다. 처음 할 때는 한 줄씩 치고 익숙해지면 합친다. 한 줄로 합치면 설치와 강제 갱신이 한 명령 안에 들어가서, 중간에서 멈췄을 때 훅이 깔린 상태인지 아닌지를 따로 봐야 한다.
**문제가 생기면** — `sudo` 가 조용히 빈 결과를 주면 tty 가 붙지 않았다. `ssh -t` 로 다시 붙는다.
## 주입 검증
갱신을 걸기 전에 훅이 제자리에, 실행 가능한 상태로 있는지 본다. 한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.
**§7 부터 §9 까지는 호스트 셸 안에서 친다.** §6 ③에서 나왔으므로 §6 ①의 `ssh -t test-server` 로 다시 들어간 뒤 아래를 친다 — `sudo` 가 비밀번호를 물으니 `-t` 가 붙은 쪽으로 들어간다. 이 세 절의 명령에는 앞에 `ssh` 가 없는데, 호스트 셸 안에 있다는 전제이기 때문이다. dev 머신에서 그대로 치면 `/etc/letsencrypt/` 가 없어 엉뚱한 결과를 보게 된다.
### 7. 훅이 제자리에 있는지 본다
**무엇을 보는가** — 파일의 권한·위치·소유자.
```bash label="[test-server] deploy 디렉터리를 다시 본다"
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
```
**어디를 보나** — 형태는 이렇다(모양은 observed).
```text
total 4
-rwxr-xr-x 1 root root 40 Sep 4 21:2x reload-nginx.sh
```
세 가지를 본다. `x` 비트(`-rwxr-xr-x`)가 있는가 — 없으면 certbot 이 그냥 건너뛴다. 디렉터리가 `deploy/` 인가 — `post/` 에 들어가면 매번 돈다. 소유자가 `root` 인가.
### 8. 훅을 손으로 한 번 돌린다
**목적** — 가장 확실한 사전 점검이다. 훅 스크립트가 실제로 도는지 본다.
**1.** 훅을 직접 실행한다.
```bash label="[test-server] 훅을 손으로 실행한다"
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
**예상 결과** — 형태는 이렇다(모양은 observed).
```text
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 의 `ps` 줄을 여기서 한 번 더 쳐서 새 워커 PID 로 바꿔 적는다 — 지금은 호스트 셸 안이므로 그 줄에서 `ssh test-server` 를 떼고 큰따옴표 안쪽만 친다. 가이드는 이 재측정을 지시만 하고 명령을 다시 싣지 않았다. 건너뛰면 §10 의 「워커가 바뀌었다」가 훅이 한 것인지 여기서 손으로 돌린 것이 한 것인지 갈리지 않는다.
**문제가 생기면** — `nginx -t` 가 실패하면 `&&` 뒤가 안 돌고 워커도 안 바뀐다. `nginx.conf` 를 고친 뒤 다시 친다.
certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래는 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[test-server] dry-run 으로 훅 호출만 본다 (unknown)"
sudo certbot renew --dry-run
```
출력에 `Running deploy-hook command` 계열의 줄이 나오는가, 그리고 `simulated renewals` 요약을 본다. dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 주고, 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다. 그래서 관찰 절이 필요하다.
## 관찰
### 9. 강제 갱신을 친다
**목적** — 인증서 한 장을 실제로 발급하고, 훅이 거기에 붙어 도는지 본다. 되돌릴 수 없다.
**1.** 시작 시각을 남긴다. 호스트에서 찍은 것은 `(ts)` 이고 106초 빠르다.
```bash label="[test-server] ① 시작 시각을 ts 시계로 남긴다"
date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
```
**2.** 강제 갱신을 건다.
```bash label="[test-server] ② 강제 갱신"
sudo certbot renew --force-renewal
```
**예상 결과** — 실측은 이렇다(observed, `02-certbot-with-hook.txt`).
```text
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 이 무엇을 찍는지 재지 않았다(unknown). 그래서 판정은 문구가 아니라 워커 PID 로 한다.
**문제가 생기면** — 발급 한도에 걸렸으면 이번 주에 중복 인증서 5장을 이미 썼다는 뜻이다. 다음 주까지 기다린다.
**여기서 호스트 셸을 나온다.** §10 부터의 명령은 앞에 `ssh test-server` 가 붙어 있거나 `[dev]` 라벨이 달려 있고, 둘 다 dev 머신에서 친다. `[dev]` 가 붙은 `openssl s_client` 세 줄은 호스트 안에서 쳐도 그대로 돌아가므로 잘못 친 것이 화면에 드러나지 않는다. 그런데 §12 의 보정은 「dev 에서 본 시각은 그대로 쓰고 호스트에서 본 시각에서만 106초를 뺀다」 위에 서 있어서, 어느 기계에서 쟀는지를 섞으면 뺄 값이 어긋나고 1~2초짜리 답이 통째로 없어진다.
### 10. 워커 PID 로 판정한다
**무엇을 보는가** — 1번과 8번에서 적어 둔 값과 지금의 값.
```bash label="[test-server] nginx 프로세스 두 줄을 본다"
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
```
**어디를 보나** — 실측은 이렇다(observed, `03-after-state.txt`).
```text
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 되었는가」를 판정하려고 세운 방법이 그대로 작동한다. 그리고 이번에는 사람이 아니라 훅이 했다 — 왼쪽 칸의 워커 `28829` 는 D-4 에서 사람이 친 `nginx -s reload` 가 만들었고, 오른쪽 칸의 `37252` 는 `deploy/` 훅이 만들었다.
`etimes 74` 를 같이 보는 까닭은 PID 가 우연히 재사용될 수 있기 때문이다. `lstart` 와 `etimes` 가 「방금」을 가리켜야 진짜 새 워커다.
### 11. 서빙 인증서가 바뀌었는지 본다
**무엇을 보는가** — 밖으로 나가는 인증서의 일련번호와 SAN(Subject Alternative Name, 한 인증서가 담는 이름 목록).
```bash label="[dev] 일련번호와 이름 셋을 본다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates -ext subjectAltName
```
**어디를 보나** — 실측은 이렇다(observed, `03-after-state.txt`).
```text
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` 이 2번에서 적어 둔 값과 다르다. D-4 의 인증서(`06C7CB…EA1D`)에서 바뀌었고 SAN 은 세 이름 그대로다. 훅 하나로 ①②가 끝났고 남은 것은 「얼마나 빨랐나」다.
### 12. 시계를 보정해 발급과 서빙 사이를 잰다
**무엇을 보는가** — 가진 시각은 셋이고 두 개는 다른 시계에서 왔다.
| 사건 | 원래 값 | 어느 시계 |
|---|---|---|
| 인증서 발급 | 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) |
```bash label="[dev] 새 인증서의 SCT 를 본다"
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
```
**어디를 보나** — 실측은 이렇다(observed, `01-hook-verified.txt`).
```text
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
```
`(ts)` 값에서 106초를 뺀다.
```text
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 12:27:49`) | `+107초` | 훅이 발급보다 107초 뒤로 보인다. 참값 1~2초보다 약 106초 크다 |
| 106초를 반대쪽에 건다 | 훅이 발급보다 앞 | 음수 지연이다. 훅은 갱신이 끝나야 도니 성립하지 않는다 |
원본은 앞 칸의 수치(`+107초`)에 뒷 칸의 결론(「104초 먼저」)을 이어 붙였다. `+107초` 는 「뒤」이므로 거기서 「먼저」가 나오지 않고, `104` 라는 수가 어느 계산에서 나왔는지도 그 문서에 남아 있지 않다(unknown). 고쳐 쓰지 않고 어긋난 채로 적어 둔다. 어느 계산으로 가든 두 시계에서 온 값을 그대로 빼면 안 된다는 것은 같기 때문이다.
음수 지연이 나오면 계산이 아니라 시계를 의심한다. 그 의심을 가르는 것은 제3의 시계다 — 여기서는 CT 로그의 SCT 였다.
### 13. `notBefore` 를 발급 시각으로 쓰지 않는다
**무엇을 보는가** — 인증서에 적힌 `notBefore` 와 SCT 의 차이.
인증서에는 `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초 |
**이 값이 뜻하는 것** — 이 차이의 원인은 이 실험이 규명하지 않았다(unknown). 다만 시각의 기준으로는 SCT 를 쓴다. 그것이 보정을 자기 검증한 값이기 때문이다. `notBefore` 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.
### 14. D-4 와 나란히 놓는다
| 무엇 | 훅 없음 (D-4) | 훅 있음 (D-4a) |
|---|---|---|
| 갱신 → 서빙 | `2305초` = `38분 25초` | 1~2초 |
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | 해당 없음 |
| 차이 | | 약 1150배 |
바뀐 것은 파일 하나, 두 줄이다.
**부수 정정이 하나 딸려 나왔다 — 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-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다. 정말 지워야 한다면 두 줄이다.
```bash label="[test-server] ① 훅을 지운다"
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
```
```bash label="[test-server] ② 다시 비었는지 본다"
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
```
다시 `total 8` 인가를 본다.
**시간이 지나야 시험할 수 있는 항목이 하나 있다.**
| 항목 | 상태 |
|---|---|
| `certbot-renew.timer` 가 실제 갱신을 하는가 | 미측정. 만료 30일 전에야 조건이 성립한다 — 증거의 `VALID: 89 days` 는 만료까지이므로 갱신은 약 59일 뒤다 |
훅은 `--force-renewal` 로 검증했다. 타이머가 스스로 갱신하는 경로도 같은 `certbot renew` 를 부르고 같은 `deploy/` 훅을 실행하므로 미지수는 「타이머가 뜨는가」 하나이고, 그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
그날이 오면 두 줄이면 된다.
```bash label="[test-server] ① 워커가 갱신 시각 근처에 떴는가"
ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep"
```
```bash label="[dev] ② 서빙 인증서의 만료가 밀렸는가"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -enddate
```
워커 `lstart` 가 갱신 시각 근처인가, 그리고 `notAfter` 가 밀렸는가를 본다. 문구가 아니라 이 둘로 판정한다.
| 항목 | 명령 | 이렇게 되어 있어야 한다 |
|---|---|---|
| 훅 | `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` | 관찰 절의 새 일련번호 |
| 체인 | D-4 의 체인 확인 한 줄 | 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 |
| 훅이 아예 안 불렸다 | `x` 비트가 없거나 `deploy/` 가 아니다 | `sudo ls -l …/deploy/` |
| 훅은 돌았는데 워커가 안 바뀐다 | `nginx -t` 가 실패해 `&&` 뒤가 안 돌았다 | 훅을 손으로 실행 |
| 워커도 마스터도 바뀌었다 | reload 가 아니라 재시작됐다 | `lstart` 두 줄을 본다 |
| 지연이 음수로 나온다 | 두 시계를 그대로 뺐다 | 시계 재는 절차로 돌아간다 |
| 발급 시각이 한 시간 어긋난다 | `notBefore` 를 발급 시각으로 읽었다 | SCT 를 본다 |
| 시계 왜곡을 지금 재려는데 값이 흔들린다 | 네트워크 지연이 섞였다 | 3회 이상 재서 안정적인지 본다 |
| 호스트 명령이 조용히 빈 결과 | sudo 가 비밀번호를 못 물었다 | `ssh -t` 로 다시 |
| 훅 로그를 `/tmp` 에 썼는데 안 보인다 | `certbot-renew.service` 는 `PrivateTmp=true` | `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다(unknown) |
| nginx 경고가 계속 거슬린다 | `types_hash_max_size` 기본값 | 갱신과 무관하다. 고치려면 `nginx.conf` 를 손본다 |
이 편이 남기는 한 문장은 「처방을 적었으면 시험한다」이다. D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었고, 그 처방이 듣는지 확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다. 확인하지 않은 채로 문서에 남았다면 「고치는 법」 항목은 다음 갱신일까지 아무도 시험하지 않은 문장으로 남았을 텐데, 그날이 바로 시험할 수 없는 날이다.
## 무엇이 관측이고 무엇이 아닌가
- (observed) 주입 전 워커 두 줄(`585` 와 `28829`, `lstart Fri Sep 4 18:00:35`), 훅 디렉터리의 `total 8`, 시계 측정 네 줄과 `+106.1` 세 번, certbot 출력 전문(`ran with error output` · `types_hash` 경고 두 줄 · `test is successful` · `signal process started` · `Congratulations, all renewals succeeded` · `fullchain.pem (success)`), 주입 뒤 워커 두 줄(`585` · `37252` · `etimes 74` · `lstart Fri Sep 4 21:29:36 2026`), 새 인증서의 `serial=06F3E0EF4D1BB03DE58130EAAD1176101373` · `notBefore=Sep 4 11:29:18 2026 GMT` · `notAfter=Dec 3 11:29:17 2026 GMT` 와 SAN 세 이름, SCT 두 줄(`Sep 4 12:27:49.054` · `Sep 4 12:27:49.048`), `notBefore` ↔ SCT 표의 `약 89.8초` · `약 88.9초`.
- (observed, 호스트 확인) `nginx.service` 의 `Restart=on-failure` · `RestartUSec=100ms` · `StartLimitBurst=5` · `StartLimitIntervalUSec=10s`, `certbot-renew.service` 의 `PrivateTmp=true`. 증거 파일이 아니라 이 호스트에서 확인한 값이라 가이드가 실측(호스트)으로 따로 표시했다.
- **이 편이 잰 reload 는 훅이 걸었다** — 워커 `37252` 를 만든 것은 `deploy/` 훅이고, 주입 전 워커 `28829` 는 D-4 에서 사람이 친 `nginx -s reload` 가 만들었다. 10번 표의 「전 / 후」 두 칸이 사람과 훅이다. 1~2초는 훅이 건 reload 를 잰 값이고, D-4 의 `2305초` 는 사람이 건 reload 까지의 간격이다. **무중단 판정의 `8856건` 과 `845361` 바이트는 이 편의 값이 아니다** — D-4 가 사람이 건 reload 에서 쟀고, 이 편은 폴링도 전송 중 요청 감시도 돌리지 않았다.
- **`2199` → `2305` 는 이 실험이 앞 실험을 정정했다** — D-4 가 `archive/cert2.pem` 의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 빼서 `2199초` 로 적었고, 여기서 시계 왜곡 106초를 재고 나서 `2305초` 로 고쳤다. D-4 에서는 106초가 결론을 안 바꿨지만 여기서는 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나와 성립하지 않는다. 정정한 값과 정정 전 값을 둘 다 남겨 둔 까닭이 그것이다.
- (unknown) `certbot renew --dry-run` 에서 `Running deploy-hook command` 줄이 나오는지 — 이 실험대는 곧바로 강제 갱신을 했다. 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, 훅이 `/tmp` 에 남긴 로그가 `PrivateTmp` 때문에 안 보이는지, `notBefore`+1시간과 SCT 사이 약 89초 차이의 원인. 가이드가 전부 미검증으로 표시했다.
- **두 형태로 적은 곳이 둘이다** — 훅 파일을 만드는 것과 설치·갱신을 한 줄로 합치는 것. 이 실험대는 `printf … > /tmp/reload-nginx.sh` 로 만들고 설치와 강제 갱신을 한 줄로 쳤다(observed). 편집기로 여는 형태와 한 줄씩 치는 형태는 이 형태로 실행하지 않았다(unknown). 실제로 친 줄을 지우지 않고 나란히 적었다.
- **비밀은 이 편에 나오지 않는다** — 다루는 값이 훅 파일 두 줄과 PID 와 시각이라 옮길 비밀이 없다. 일련번호·PID·호스트명은 식별자라 그대로 적었다. 새 `privkey2.pem` 도 D-3 의 문제를 그대로 안고 있지만 이 절차는 그 파일을 열지 않는다.
- **이 실험이 확인하지 않은 것** — 타이머가 스스로 갱신하는 경로. 만료 30일 전에야 조건이 성립하고(증거의 `VALID: 89 days` 는 만료까지이므로 갱신은 약 59일 뒤다), 그때 볼 두 줄만 적어 두었다.
- **가이드가 「다음」에 적은 한 줄이 이 편의 결론이기도 하다** — 「이 훅은 구축 절차에 들어가야 한다. 사후에 붙이는 것이 아니다」. D-4 가 잰 `38분 25초` 의 공백은 훅이 없어서 생긴 것이고, 그 훅은 인증서를 처음 세울 때 같이 놓였어야 했다.
<!-- body:end -->
@@ -0,0 +1,134 @@
---
kind: CASE
slug: cache-temperature-decides-the-outcome
title: 같은 설정이 캐시 온도만으로 세 가지 답을 냈다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택의-이유와-지킨-경계-a7-a7a
assets:
- key: cache-temperature-outcomes
file: ../../../final/assets/cache-temperature-outcomes/cache-temperature-outcomes.svg
evidence:
- ../../../final/evidence/raw/a7a-volatile-cause__01-cause-determined.txt
- ../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt
---
# 같은 설정이 캐시 온도만으로 세 가지 답을 냈다
캐시 온도만 달라도 같은 명령이 400, 500, 200 세 가지로 갈렸다. refresh 가 쏘는 SQL 은 가설로 둔 REVOKED_TOKEN 이 아니라 CLIENT_SCOPE_CLIENT 조회 한 문장이었고, 그것도 첫 refresh 한 번뿐이라 그 뒤로는 캐시에서 답한다.
## 관계
- **persistent-user-sessions 가 세션의 거처를 정한다**
이 측정은 그 설정을 끈 쪽에서 잰 것이고, 껐을 때 무슨 일이 일어나는지를 숫자로 대는 사례다.
- **버전과 설정을 결과와 함께 적는다**
한 번 재고 표로 적으면 안 되는 종류가 있다는 것을 이 세 결과가 보였고, 그 규칙을 편 기록이다.
## 문제
persistent-user-sessions 를 끄고 A층을 다시 돌렸더니 세 결과가 뒤집혔고, 그 실험이 표 하나를 남겼다. 표에는 데이터베이스를 세운 동안 로그인은 200 이고 refresh 는 500 이라고 적혀 있었다.
500 의 원인은 확정하지 못했고 REVOKED_TOKEN 테이블일 것이라는 가설만 붙어 있었다. 세션을 메모리에 두면 데이터베이스를 안 볼 텐데 왜 refresh 만 실패하는지가 설명되지 않았기 때문이다.
가설이 맞는지 재지 않은 채로 두면 「volatile 이면 이렇다」는 표가 조건 없이 유통된다.
## 결론
원인은 REVOKED_TOKEN 이 아니라 선택적 클라이언트 스코프 조회였다.
로그인이 쏘는 SQL : 0건
refresh 가 쏘는 SQL : CLIENT_SCOPE_CLIENT 한 문장
그 조회의 조건 : DEFAULT_SCOPE = 'f'
REVOKED_TOKEN 이 나온 횟수 : 0
그 조회가 일어나는 때 : 첫 refresh 한 번. 이후로는 캐시에서 답한다
캐시가 그 조회를 삼키는 순간 결과가 바뀌므로, 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. 완전 냉시동이면 로그인부터 400 이고, CLIENT 만 더우면 로그인 200 에 refresh 500 이며, 완전히 더우면 둘 다 200 이다.
그래서 원래 표가 적은 「volatile 이면 데이터베이스 없이 로그인된다」도 조건부였다. 냉시동에서는 클라이언트 조회조차 캐시에 없어 로그인이 400 이 된다.
## 검증 환경
Keycloak : 26.7.0 · 2노드
기능 플래그 : --features-disabled=persistent-user-sessions
세션 위치 : 메모리. offline_user_session 행 수 0 으로 확인
데이터베이스 : PostgreSQL
SQL 관측 : PostgreSQL 문장 로깅
수집 기록 : 2026-09-04 11:1811:24 UTC. PostgreSQL 컨테이너가 UTC 로 로그를 찍는다
구간 표시 : 로그인과 refresh 앞뒤에 표식 SELECT 를 넣어 어느 SQL 이 어느 요청 것인지 가른다
## 재현 조건
1. Keycloak 을 --features-disabled=persistent-user-sessions 로 띄운다.
2. 로그인한 뒤 offline_user_session 행 수가 0 인지 확인한다. 0 이어야 세션이 메모리에 있는 상태다.
3. PostgreSQL 문장 로깅을 켠다.
4. 로그인과 refresh 를 각각 표식 SELECT 로 감싸 그 사이에 나오는 SQL 을 가른다.
5. 냉시동을 만든다. Keycloak 을 재시작하고 PostgreSQL 을 내린 다음 로그인한다.
6. 중간 상태를 만든다. Keycloak 을 재시작하고 데이터베이스가 살아 있을 때 로그인을 한 번 한 다음 PostgreSQL 을 내리고 refresh 한다.
7. 완전히 더운 상태를 만든다. refresh 를 세 번 미리 돌려 캐시를 채운 다음 PostgreSQL 을 내리고 로그인과 refresh 를 각각 보낸다.
8. 세 경우의 상태 코드와, 실패한 경우 로그가 지목한 SQL 문장을 함께 적는다.
## 본문
<!-- body:start -->
## 가설은 REVOKED_TOKEN 이었다
`--features-disabled=persistent-user-sessions` 는 세션을 데이터베이스에 쓰지 않고 메모리에 두게 하는 설정이다. 이 상태로 A층을 다시 돌리자 세 실험의 결과가 뒤집혔고, 그중 하나가 데이터베이스를 세운 동안 로그인은 `200` 인데 refresh 는 `500` 이 되는 것이었다.
세션이 메모리에 있으면 refresh 도 데이터베이스를 볼 이유가 없다. 그 실험은 「측정은 확실하지만 원인은 확정하지 못했다」고 적고 유력한 후보로 `REVOKED_TOKEN` 테이블을 남겼다. refresh token 회전은 이미 쓴 토큰이 다시 왔는지 확인해야 하고, 그 확인은 발급 이력을 담은 테이블을 읽어야 하니 그 경로가 캐시를 못 쓸 것이라는 추측이었다.
## 문장 로깅으로 실제 SQL 을 잡았다
PostgreSQL 의 `log_statement``all` 로 올려 오가는 문장을 전부 남기고, 로그인과 refresh 앞뒤에 표식 SELECT 를 하나씩 넣어 어느 문장이 어느 요청 것인지 갈랐다.
로그인은 SQL 을 0개 쏜다. realm 과 사용자, 클라이언트가 전부 Infinispan 캐시에 있어서 데이터베이스를 보지 않는다. refresh 는 딱 한 문장을 쏜다. 그 문장이 이것이다.
```text label="refresh 가 쏜 단 한 문장"
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'
```
`REVOKED_TOKEN` 은 한 번도 나오지 않았다. `DEFAULT_SCOPE='f'` 이므로 이것은 선택적 클라이언트 스코프 조회다. 클라이언트 스코프는 그 클라이언트에게 발급할 토큰에 어떤 권한 범위를 담을지를 묶어 둔 설정이고, 기본 스코프는 항상 들어가지만 선택적 스코프는 요청이 달라고 해야 들어간다. refresh 는 새 access token 에 무엇을 담을지 다시 계산하므로 그 목록을 읽어야 하고, 목록이 이 테이블에 있다.
## 그 조회는 첫 refresh 한 번뿐이다
refresh 를 연속 세 번 돌리고 표식 사이의 SQL 을 다시 셌더니 0건이었다. 첫 refresh 가 캐시를 채우고 그다음부터는 데이터베이스를 보지 않는다.
그러면 데이터베이스를 세웠을 때 무엇이 실패하는지는 그 순간 캐시가 무엇을 이미 갖고 있느냐로 정해진다. 셋을 각각 만들어 재 봤다.
| 캐시 상태 | 로그인 · refresh 가 받는 것 | 어느 SQL 이 실패했나 |
|---|---|---|
| 완전 냉시동 | `400` · `400` | `select ce1_0.ID from CLIENT ...` |
| CLIENT 만 더움 | `200` · `500` | `CLIENT_SCOPE_CLIENT ...` |
| 완전히 더움 | `200` · `200` | 없음 (SQL 0건) |
![냉시동에서는 클라이언트 조회가, 반쯤 더운 상태에서는 스코프 조회가 데이터베이스에 닿아 실패하고, 완전히 더운 상태에서는 어느 쪽도 닿지 않는 구성.](../../../final/assets/cache-temperature-outcomes/cache-temperature-outcomes.svg)
세 결과를 만드는 것은 조회 두 개다. 요청이 먼저 클라이언트를 확인하고, 그다음 스코프를 다시 계산한다. 캐시가 둘 다 못 삼킨 상태면 앞의 조회에서 400 이 나고, 앞은 삼켰는데 뒤는 못 삼킨 상태면 500 이 나며, 둘 다 삼킨 뒤에는 데이터베이스에 닿는 조회 자체가 없다.
## 원래 표가 본 것은 그 사이의 한 상태였다
앞의 실험은 Keycloak 을 재시작하고 로그인을 한 번 한 다음 데이터베이스를 내렸다. 로그인이 CLIENT 캐시를 채웠고 refresh 는 한 번도 돌지 않아 스코프 캐시는 비어 있었으므로, 표에 적힌 「로그인 200, refresh 500」은 그 중간 상태에서 나온 값이었다.
같은 표의 다른 줄도 마찬가지다. 「volatile 이면 데이터베이스 없이 로그인된다」는 냉시동에서 성립하지 않는다. 재시작 직후에는 클라이언트 조회조차 캐시에 없어서 로그인이 `400` 으로 떨어지고, 로그는 `select ce1_0.ID from CLIENT` 가 실패했다고 지목한다.
세션을 메모리에 두는 구성에서 데이터베이스가 멈췄을 때의 동작은 무엇을 하느냐가 아니라 그 경로가 이미 캐시를 채웠느냐로 결정된다. 그래서 같은 명령이 재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다. 이런 종류의 결과는 한 번 재보고 표로 적으면 안 되는데, 앞 실험이 그렇게 했다.
## 확인하지 않은 것
캐시가 식는 시간을 재지 않았다. 냉시동과 중간, 완전히 더운 세 상태를 만들어 확인했을 뿐 그 사이의 전이는 관측하지 않았다.
세 상태는 재시작과 요청 횟수로 만든 것이라, 운영에서 얼마나 오래 쓰지 않으면 어느 상태로 돌아가는지는 이 측정으로 답할 수 없다.
<!-- body:end -->
@@ -0,0 +1,136 @@
---
kind: CASE
slug: rolling-restart-keeps-sessions-drops-cache
title: 롤링 재시작은 세션을 남기고 캐시만 지웠다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택의-이유와-지킨-경계-a8
assets:
- key: a8-cache-vs-session
file: ../../../final/assets/a8-cache-vs-session/a8-cache-vs-session.svg
evidence:
- ../../../final/evidence/raw/a8-rolling-restart__01-restart-availability.txt
- ../../../final/evidence/raw/a8-rolling-restart__02-session-survival.txt
---
# 롤링 재시작은 세션을 남기고 캐시만 지웠다
Keycloak 2노드를 롤링 재시작했더니 데이터베이스 세션은 151 개 그대로였고 노드의 세션 캐시만 초기화됐다. 재시작 전에 발급한 refresh token 도 200 을 받았다. persistent-user-sessions 를 끄면 같은 재시작이 전원 로그아웃이 된다. 재시작 중 외부 진입점은 5초 해상도에서 끊김이 관측되지 않았다.
## 관계
- **persistent-user-sessions 가 세션의 거처를 정한다**
이 실험이 남긴 차이가 그 설정을 켜는 이유이고, 끈 쪽에서는 같은 재시작이 전원 로그아웃이 된다.
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
세션이 데이터베이스에 있고 캐시는 각 노드의 사본이라는 구분이 이 측정의 전제다.
## 문제
파드를 새 설정이나 새 버전으로 바꾸려면 한 번은 재시작해야 한다. 그때 이미 로그인해 둔 사용자가 계속 로그인 상태인지, 아니면 전부 다시 로그인해야 하는지를 알아야 했다.
세션은 데이터베이스에 있고 각 노드는 자기가 처리한 로그인만 캐시에 담는데, 이 둘이 재시작에서 같이 없어지는지 따로 노는지는 재 보지 않았다.
## 결론
재시작은 캐시만 지웠고 세션은 건드리지 않았다.
DB 세션 수 : 151 에서 151
세션 캐시 keycloak-0 : 0.0 건
세션 캐시 keycloak-1 : 1.0 건
재시작 전 발급한 refresh token : 200
재시작 중 외부 진입점 : 5초 해상도에서 끊김이 관측되지 않았다. 표본 9개
keycloak-1 의 1건은 재시작에서 살아남은 엔트리가 아니라 방금 refresh 를 처리하며 새로 담은 것이다.
persistent-user-sessions 를 켜는 이유가 여기에 있다. 같은 롤링 재시작을 그 설정 없이 돌리면 재시작 뒤 refresh 가 400 Session not active 가 되고 로그인해 둔 사용자가 전부 빠진다.
처음 적을 때는 표본 9개로 무중단을 주장했다. 그 표본 수로는 평시 오류율과 견줄 수 없어서, 5초 해상도에서 끊김이 관측되지 않았다는 데까지로 주장을 낮췄다.
## 검증 환경
Keycloak : 26.7.0 · 2노드
워크로드 종류 : StatefulSet
persistent-user-sessions : 기본값 그대로 켬
세션 저장소 : PostgreSQL
캐시 수 관측 : Prometheus 의 세션 캐시 엔트리 수
세션 수 관측 : PostgreSQL 직접 조회
재시작 방법 : kubectl rollout restart
실험대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
## 재현 조건
1. Keycloak 을 2노드로 띄우고 세션을 미리 만들어 둔다.
2. 재시작 전 상태를 두 가지로 적어 둔다. PostgreSQL 의 온라인 세션 수와, 방금 로그인해 받은 sid 및 refresh token.
3. 롤링 재시작을 건다. kubectl rollout restart
4. 재시작이 도는 동안 외부 진입점을 일정 간격으로 찍어 상태 코드를 시계열로 남긴다. 표본 수를 함께 적는다.
5. 재시작이 끝나면 2 번에서 받아 둔 refresh token 을 그대로 보내고 상태 코드를 본다.
6. PostgreSQL 에서 그 sid 의 행이 남아 있는지와 전체 온라인 세션 수를 다시 센다.
7. 노드별 세션 캐시 엔트리 수를 Prometheus 에서 읽는다.
8. 파드 나이를 확인해 실제로 교체됐는지 대조한다.
## 본문
<!-- body:start -->
## 재시작 전에 두 가지를 따로 적어 두었다
Keycloak 에서 로그인 한 건은 두 곳에 흔적을 남긴다. 하나는 PostgreSQL 의 세션 행이고 다른 하나는 그 로그인을 처리한 노드의 Infinispan `sessions` 캐시 엔트리다. Infinispan 은 Keycloak 이 세션과 realm 정보를 올려 두는 인메모리 데이터 그리드라 프로세스가 내려가면 그 안의 것도 같이 없어진다.
그래서 재시작 전에 두 값을 각각 적었다. PostgreSQL 의 온라인 세션 수는 151 이었고, 방금 로그인해 받은 sid 와 refresh token 을 파드 안에 보관해 두었다. 이 둘을 나눠 세지 않으면 재시작 뒤에 로그인이 유지되는지 아닌지만 알 수 있고 무엇 덕에 유지됐는지는 알 수 없다.
## 재시작 뒤에 남은 것과 사라진 것
롤링 재시작은 한 번에 한 파드씩 바꾼다. 2노드를 전부 교체한 뒤 같은 두 값을 다시 셌다.
| 무엇을 봤나 | 재시작 전 | 재시작 후 |
|---|---|---|
| DB 세션 수 | 151 | 151 |
| 세션 캐시 | 엔트리 있음 | keycloak-0 `0.0` 건 · keycloak-1 `1.0` 건 |
| 앞서 발급한 refresh token | `200` | `200` |
| 외부 진입점 | `200` | 전 구간 `200` |
keycloak-1 의 `1.0` 건은 재시작에서 살아남은 엔트리가 아니라 방금 refresh 를 처리하며 새로 담은 것이다.
파드가 죽으면 그 노드의 캐시는 함께 없어지는데, 세션 행은 PostgreSQL 에 있어서 재시작과 무관하다. 그래서 재시작 전에 발급한 refresh token 이 새로 뜬 파드에서도 통한다.
![재시작으로 Infinispan 캐시가 0 이 되지만 PostgreSQL 의 세션 행은 151 개 그대로여서 refresh 가 계속 통하는 구성.](../../../final/assets/a8-cache-vs-session/a8-cache-vs-session.svg)
그림에서 롤링 재시작이 캐시로 보내는 화살표는 초기화이고 세션 행으로 보내는 화살표는 변경 없음이다. refresh token 이 통하는 이유는 그 토큰이 캐시가 아니라 세션 행을 거쳐 확인되기 때문이다.
## 설정 하나를 끄면 같은 재시작이 전원 로그아웃이 된다
`persistent-user-sessions` 는 Keycloak 26 에서 기본으로 켜져 있고 세션을 데이터베이스에 쓰게 한다. 24 이전은 그렇지 않아서 세션을 메모리에 두고 Infinispan 으로 복제했다.
그 설정을 끄고 같은 롤링 재시작을 다시 돌리면 재시작 뒤의 refresh 가 `400 Session not active` 가 된다. 세션이 파드와 함께 없어졌으니 남은 노드도 그 세션을 모르고, 로그인해 둔 사용자가 전부 빠진다. 위 표의 「DB 세션 수 151」은 그 설정을 켜 두었을 때의 값이다. 같은 롤링 재시작인데 한쪽은 견디고 한쪽은 못 견디는 것이 세션을 어디에 두었느냐에서 갈린다.
## 무중단이라고 적은 근거가 처음에는 모자랐다
재시작 중 외부 진입점을 5초 간격으로 찍었고 받은 상태 코드는 전부 200 이었다. 처음 기록할 때는 그 표본 9개로 중단이 없었다고 적었다.
9개로는 평시 오류율과 견줄 수 없다. 주입 전 평시를 재 두지 않으면 같은 관측이 「영향 없음」으로도 「원래 그랬음」으로도 읽히기 때문에, 이 실험대에서는 대조군 없이 귀속하지 않는다는 규칙을 세우고 어긴 곳을 찾아 고쳤다. 이 주장도 그때 함께 고친 둘 중 하나다.
계획서가 이 실험에 적어 둔 예상은 재시작 중 refresh 가 「일시 실패 후 성공 (파드 전환 시점)」이라는 것이었다. 5초 간격으로 찍은 것은 외부 진입점의 상태 코드라, 파드가 바뀌는 순간에 그런 실패가 있었는지는 이 관측으로 답하지 못한다.
재현 절차를 점검할 때 한 가지가 더 나왔다. 이 실험의 토큰 전달이 `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽는 식으로 적혀 있어서 실제로는 빈 토큰을 보내고 있었다. 절차를 셸 표현식으로 바꾸고 실행해 보기 전까지는 드러나지 않았다.
## 확인하지 않은 것
표본이 적어 무중단 주장을 처음에 과장했다가 고쳤다. 재시작 중 진행 중이던 요청은 재지 않았다.
5초 간격 폴링은 요청을 보내고 응답을 받는 것까지만 본다. 재시작 순간에 이미 처리 중이던 요청이 어떻게 끝나는지는 이 방식으로 관측되지 않는다.
<!-- body:end -->
@@ -0,0 +1,147 @@
---
kind: CASE
slug: session-sharing-is-the-database-not-replication
title: 클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#코드보다-먼저-드러난-문제-전제가-무너졌다
- final/document.md#선택의-이유와-지킨-경계-a1
assets:
- key: session-sharing-path
file: ../../../final/assets/session-sharing-path/session-sharing-path.svg
- key: a1-transport-vs-discovery
file: ../../../final/assets/a1-transport-vs-discovery/a1-transport-vs-discovery.svg
evidence:
- ../../../final/evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt
- ../../../final/evidence/raw/a1-jgroups-transport-block__10-logout-not-propagated.txt
---
# 클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다
두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 읽기 때문이었다. 노드 B 가 PostgreSQL 로 날린 SQL 을 문장 로깅으로 잡아 보니 세션 엔트리는 노드 사이를 건너가지 않았다. TCP 7800 을 끊어도 교차 노드 refresh 는 200 이었고 로그아웃 전파만 깨졌다.
## 관계
- **persistent-user-sessions 가 세션의 거처를 정한다**
세션이 데이터베이스에 있다는 이 측정의 전제를 만드는 설정이고, 그 설정을 끄면 같은 실험의 답이 갈린다.
- **같은 설정이 캐시 온도만으로 세 가지 답을 냈다**
그 설정을 끈 대조군에서 나온 결과이고, 같은 문장 로깅 기법으로 원인을 확정했다.
- **버전과 설정을 결과와 함께 적는다**
이 결론에 버전과 설정 조건이 붙는다는 것을 규칙으로 편 기록이다.
## 문제
앞선 작업의 열린 질문 네 개는 인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 모른다는 전제를 깔고 있었다. 그래서 노드를 둘로 만들고 한 노드에서 만든 세션을 다른 노드가 쓸 수 있는지부터 확인해야 했다.
답은 그렇다였다. 그런데 로그에는 클러스터 뷰가 찍혀 있고 JGROUPS_PING 테이블에도 두 노드가 등록되어 있어서, Infinispan 이 세션을 복제해서 그렇게 된다고 읽기 쉽다.
그렇게 되는 이유를 확인하지 않고 두면 이후 실험의 해석이 전부 그 위에 쌓인다. 클러스터를 끊으면 세션 공유가 깨질 것이라는 예측도 여기서 나왔다.
## 결론
두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 읽기 때문이었다.
세션 엔트리가 노드 사이로 복제됨 : x
각 노드가 캐시하는 것 : 자기가 처리한 로그인
두 노드가 함께 읽는 것 : OFFLINE_USER_SESSION
JGROUPS_PING 이 하는 일 : 서로를 발견해 클러스터 뷰를 만든다
TCP 7800 을 끊고 다시 재니 예측 둘 가운데 하나가 빗나갔다. 교차 노드 refresh 는 200 이었고, 반대편 노드에서 로그아웃한 뒤 400 이 나와야 할 재갱신도 200 이었다. 세션 조회는 데이터베이스를 거치고 로그아웃 무효화 통지는 7800 을 타므로, 전송만 끊으면 세션 공유는 살아남고 무효화 통지만 막힌다.
이 결과는 persistent-user-sessions 가 기본으로 켜진 Keycloak 26 에서 잰 것이다. 같은 실험을 그 설정 없이 돌리면 교차 노드 refresh 가 400 Session not active 로 갈린다.
## 검증 환경
Keycloak : 26.7.0 · 2노드
persistent-user-sessions : 기본값 그대로 켬
세션 저장소 : PostgreSQL
클러스터 디스커버리 : PostgreSQL 의 JGROUPS_PING 테이블
클러스터 전송 : TCP 7800
차단 방법 : NetworkPolicy 허용 목록에 8080 과 9000 만 남기고 7800 을 뺀다
SQL 관측 : PostgreSQL 문장 로깅
실험대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
## 재현 조건
1. Keycloak 을 2노드로 띄우고 JGROUPS_PING 에 두 행이 들어가는지, 클러스터 뷰가 2명인지 확인한다.
2. PostgreSQL 문장 로깅을 켠다.
3. 노드 A 로 로그인하고 그 sid 를 적어 둔다.
4. 노드 B 로 refresh 를 보내고, 그동안 노드 B 가 어떤 SQL 을 쏘는지 로그에서 확인한다.
5. NetworkPolicy 의 허용 포트를 8080 과 9000 만 남겨 7800 을 막는다.
6. 클러스터가 실제로 갈라졌는지 vendor_cluster_size 로 확인한다. ESTABLISHED 연결은 규칙 평가를 건너뛰므로, 값이 2 에서 안 내려가면 파드를 재시작해 연결을 새로 맺게 한다.
7. 분단 상태에서 노드 A 로 로그인하고 노드 B 로 refresh 를 보내 상태 코드를 본다.
8. 노드 B 에서 로그아웃한 뒤 노드 A 로 재갱신을 보내고 상태 코드를 본다. 400 이면 무효화가 전파된 것이고 200 이면 막힌 것이다.
## 본문
<!-- body:start -->
## 로그와 테이블은 클러스터가 섰다고 말한다
Keycloak 을 두 대로 올리면 두 노드는 PostgreSQL 의 `JGROUPS_PING` 테이블에 자기 행을 넣어 서로를 발견하고, 그 행들로 지금 클러스터에 누가 있는지를 나타내는 클러스터 뷰를 만든다. 뷰가 만들어지면 로그에 그대로 찍힌다.
```text label="두 노드가 한 클러스터를 이룬 로그"
ISPN000094: Received new cluster view for channel ISPN:
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
```
`JGROUPS_PING` 에도 둘 다 등록되어 있다. Infinispan 은 Keycloak 이 세션과 realm 정보를 담아 두는 인메모리 데이터 그리드이고 노드 사이로 복제하는 기능이 있으므로, 이 두 가지만 보면 세션도 그 경로로 복제된다고 읽게 된다. 실험대를 세우면서 적어 둔 예측이 그것이었고, 「클러스터를 끊으면 세션 공유가 깨진다」는 다음 예측도 거기서 나왔다.
## 노드 B 가 무엇을 읽는지 SQL 로 확인했다
노드 A 로 로그인해 세션을 만든 다음 노드 B 로 refresh 를 보내고, 그동안 노드 B 가 PostgreSQL 로 날린 SQL 을 문장 로깅으로 잡았다. 노드 B 는 `OFFLINE_USER_SESSION` 을 직접 읽고 있었다. 세션 엔트리는 노드 사이를 건너가지 않는다. 각 노드는 자기가 처리한 로그인만 자기 쪽 `sessions` 캐시에 남긴다. 그래서 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 보기 때문이다.
![keycloak-0 과 keycloak-1 이 각자 캐시를 갖고 PostgreSQL 을 함께 읽는 구성. 두 캐시 사이에는 세션 복제 경로가 없다.](../../../final/assets/session-sharing-path/session-sharing-path.svg)
그림에서 `JGROUPS_PING` 으로 들어가는 화살표는 두 노드의 멤버 등록 둘이고, `PostgreSQL` 로는 한쪽이 세션을 INSERT 하고 다른 쪽이 SELECT 한다. 두 `sessions` 캐시를 잇는 선은 없다.
클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였고, 이 하나가 이후 실험 전체의 해석을 바꿔 놓았다.
## 전송을 끊자 예측 둘 가운데 하나가 빗나갔다
앞 절이 로그인과 refresh 한 번씩으로 경로를 확인한 것이라면, 여기서는 그 경로를 실제로 끊어 본다. Keycloak 노드는 서로를 `JGROUPS_PING` 으로 찾지만 메시지는 TCP 7800 으로 주고받는다. 7800 만 끊으면 둘 다 테이블에 등록된 채로 남아 서로 존재한다고 믿으면서 메시지는 오가지 않는 상태가 된다.
NetworkPolicy 는 허용 목록이라 「7800 을 deny 한다」는 규칙을 쓸 수 없다. 그래서 8080 과 9000 만 열고 7800 을 목록에서 빼는 방식으로 막았다. 9000 은 health 와 metrics 가 쓰는 포트라 이것까지 빠뜨리면 kubelet 이 프로브 실패로 파드를 죽이고, 그러면 분단이 아니라 죽은 Keycloak 을 재게 된다.
A층 실험은 예측을 먼저 문서에 적어 두고, 주입한 뒤 관측하고, 마지막에 그 예측과 대조하는 순서로 돌렸다. 결과를 보고 나면 무엇을 예상했는지 정직하게 쓸 수 없어서 순서를 그렇게 고정했다. 여기 적어 둔 예측은 둘이었고 하나는 맞고 하나는 틀렸다.
| 무엇을 예측했나 | 실제로 무엇이 나왔나 |
|---|---|
| 세션 공유는 안 깨진다 | 맞다. 교차 노드 refresh 가 `200` |
| 로그아웃 전파는 안 깨진다 | 틀렸다. `400` 이어야 할 것이 `200` |
세션은 데이터베이스에 있으니 7800 과 무관한데, 로그아웃 무효화 통지는 7800 을 타기 때문에 끊으면 반대편 노드가 「이 세션은 죽었다」를 알 방법이 없다.
노드 B 에서 로그아웃하자 그 `sid` 의 행은 데이터베이스에서 사라졌는데도 노드 A 로 보낸 재갱신은 `200` 이었고, 그때 노드 A 의 세션 캐시에는 그 세션이 1건 남아 있었다. 캐시에 있으면 데이터베이스를 다시 읽지 않으므로, 로그아웃과 함께 행이 사라지는 것을 보고 무효화가 데이터베이스 삭제로 전파된다고 적어 두었던 앞 실험의 설명을 여기서 정정했다.
![두 노드가 데이터베이스로는 이어져 있고 TCP 7800 으로는 끊긴 구성. 세션 조회는 살아 있고 무효화 통지는 막힌다.](../../../final/assets/a1-transport-vs-discovery/a1-transport-vs-discovery.svg)
발견은 데이터베이스를 쓰고 전송은 7800 을 쓴다. 예측이 하나만 맞은 이유가 이 갈림에 있다.
## 7800 을 목록에서 뺐다고 바로 갈라지지 않았다
규칙을 적용한 뒤에도 클러스터에 지금 몇 명이 있는지를 내보내는 지표 `vendor_cluster_size` 가 25분 동안 2 로 남았다. conntrack 은 커널이 이미 맺어진 연결을 기억해 두는 표인데, ESTABLISHED 로 기록된 연결은 규칙 평가를 건너뛰기 때문에 정책을 바꿔도 기존 연결이 그대로 흘렀다. 실제로 갈라진 것은 파드를 재시작해 연결을 새로 맺게 한 뒤였다. 기록을 다 쓰고 증거와 하나씩 대조할 때 이 실험에서도 한 곳이 걸렸다. 재시작 4초 뒤에 생긴 분단을 처음에는 conntrack 이 풀린 결과라고 적어 두었다.
밖에서만 보면 이 실험은 「아무 일도 없음」으로 끝난다. `curl` 로 외부 진입점을 찍으면 분단 중에도 전부 200 이었는데, 장애가 없어서가 아니라 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠졌기 때문이다. 그래서 관측 지점을 외부 `curl` 과 Prometheus 지표와 PostgreSQL 직접 조회 셋으로 늘렸고, 위의 200 과 400 도 그 셋을 함께 보고 판정했다.
## 확인하지 않은 것
Infinispan 복제를 명시적으로 켠 구성에서는 재지 않았다. 이 결론은 `persistent-user-sessions` 가 켜진 26.7.0 기본값에 한정된다.
그 설정을 끄고 같은 차단을 다시 걸었을 때 교차 노드 refresh 가 `400 Session not active` 가 되는 것까지는 확인했다. 다만 그것은 세션을 메모리에 두는 쪽의 결과이지 복제를 켠 구성의 결과가 아니다.
<!-- body:end -->
@@ -0,0 +1,109 @@
---
kind: CONCEPT
slug: persistent-vs-volatile-user-sessions
title: persistent-user-sessions 가 세션의 거처를 정한다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
basisVersion: Keycloak 26.7.0 · persistent-user-sessions 기본 활성 · 24 이전과 대조
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#코드보다-먼저-드러난-문제-버전-조건
assets:
- key: version-conditional-results
file: ../../../final/assets/version-conditional-results/version-conditional-results.svg
evidence:
- ../../../final/evidence/raw/session-replication__01-cross-node-session.txt
- ../../../final/evidence/raw/a7-volatile-comparison__01-switch-to-volatile.txt
- ../../../final/evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt
- ../../../final/evidence/raw/a7-volatile-comparison__04-a1-rerun-partition.txt
- ../../../final/evidence/raw/a8-rolling-restart__02-session-survival.txt
- ../../../final/evidence/raw/a7-volatile-comparison__03-a8-rerun-restart.txt
- ../../../final/evidence/raw/a2-database-loss__03-four-paths.txt
- ../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt
---
# persistent-user-sessions 가 세션의 거처를 정한다
persistent-user-sessions 가 켜져 있으면 세션은 PostgreSQL 에 남고, 꺼져 있으면 노드 메모리에만 있다. Keycloak 26 은 켜진 쪽이 기본값이고 24 이전은 꺼진 쪽이 기본이었다. 이 실험대에서 그 플래그만 끄고 A층 실험 셋을 다시 돌리자 세 결과가 전부 반대로 나왔다.
## 관계
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
플래그가 켜진 쪽에서 세션이 실제로 어느 경로를 지나는지 SQL 로 확정한 기록이다.
- **롤링 재시작은 세션을 남기고 캐시만 지웠다**
같은 플래그가 켜져 있을 때 재시작이 무엇을 남기고 무엇을 지우는지 세어 본 기록이다.
- **버전과 설정을 결과와 함께 적는다**
플래그 하나로 결과가 갈린다는 것을 적는 규칙으로 편 기준이다.
## 본문
<!-- body:start -->
## 세션이 기록되는 두 곳
`persistent-user-sessions` 는 로그인한 사용자의 세션을 데이터베이스에 쓸지 노드 메모리에만 둘지 정하는 Keycloak 의 기능 플래그다. 켜져 있으면 로그인 하나가 PostgreSQL 의 세션 행 하나가 되고, 꺼져 있으면 그 세션은 로그인을 처리한 노드의 Infinispan 캐시에만 생긴다. Infinispan 은 Keycloak 에 들어 있는 분산 캐시이고, 세션을 데이터베이스에 쓰지 않을 때 다른 노드가 같은 세션을 알게 만드는 것이 이 복제다.
세션이 들어가는 테이블 이름은 `OFFLINE_USER_SESSION` 이다. 이 기능이 새 테이블을 만들지 않고 기존 오프라인 세션 테이블을 재사용하기 때문이고, `offline_flag` 컬럼으로 둘을 구분해 `'0'` 이 일반 로그인이다.
Keycloak 26 은 이 플래그가 기본으로 켜져 있고 24 이전은 꺼져 있었다. 아래에서 말하는 24 이전 동작은 Keycloak 24 를 설치해서 본 것이 아니라, 26.7.0 한 판 위에서 `--features-disabled=persistent-user-sessions` 로 그 플래그만 끄고 같은 실험을 다시 돌려 재현한 것이다.
![세션을 PostgreSQL 에 쓰는 경로와 노드 메모리에 두고 Infinispan 으로 복제하는 경로가 persistent-user-sessions 설정 하나로 갈리는 구성. 켜진 쪽에서는 세션 행이 데이터베이스에 남아 노드가 바뀌어도 읽히고, 꺼진 쪽에서는 세션이 노드 메모리에 있어 그 노드가 내려가거나 분단되면 사라진다.](../../../final/assets/version-conditional-results/version-conditional-results.svg)
## 클러스터 뷰가 생겨도 세션은 건너가지 않는다
플래그가 켜진 상태로 노드 두 대를 올리면 로그에 클러스터 뷰가 찍힌다.
```text label="로그에 찍힌 클러스터 뷰"
ISPN000094: Received new cluster view for channel ISPN:
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
```
두 노드는 `JGROUPS_PING` 테이블에 자기를 등록해서 상대를 찾는다. 그런데 노드 A 로 로그인하고 노드 B 로 refresh 하면, 노드 B 는 PostgreSQL 로 SQL 을 보내고 문장 로깅이 그 문장을 기록한다. 세션 엔트리는 노드 사이를 건너가지 않고, 각 노드는 자기가 처리한 로그인만 캐시한다.
문장 로깅은 데이터베이스가 받은 SQL 을 한 문장씩 기록하도록 켜 두는 설정이다. 노드 B 가 캐시에서 답했는지 데이터베이스를 읽고 답했는지가 여기서 갈린다.
두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 보기 때문이다. `sessions` 캐시 사이에는 경로가 없고 둘 다 `OFFLINE_USER_SESSION` 을 읽는다.
## 플래그를 끄면 같은 실험이 반대로 끝난다
`--features-disabled=persistent-user-sessions` 로 세션을 노드 메모리에만 두고 A층 실험 셋을 다시 돌렸다.
| 실험 | persistent (26 기본) | volatile (24 이전) |
|---|---|---|
| A-1 · 7800 차단 후 교차 노드 refresh | `200` — 안 깨진다 | `400 Session not active` — 깨진다 |
| A-8 · 롤링 재시작 후 refresh | `200` — 세션 생존 | `400 Session not active` |
| A-2 · DB 정지 중 새 로그인 | `500` | `200` — 된다 |
7800 은 JGroups 가 노드 사이 전송에 쓰는 TCP 포트다. 세션이 데이터베이스에 있으면 이 포트를 끊어도 반대편 노드가 같은 행을 읽어 refresh 가 `200` 으로 끝난다. 세션이 노드 메모리에만 있으면 로그인을 처리한 노드와 refresh 를 받은 노드가 갈라진 채로 남아 `400 Session not active` 가 된다.
A-1 줄의 뒤집힘은 재고 나서 안 것이 아니다. 세션이 어디 있는지를 처음 확인한 직후, 아직 아무것도 주입하기 전에 예측표를 적었고 거기에 「7800 차단이 A-1과 정반대로 치명적이 된다」가 들어 있었다. 근거 칸은 「그때는 캐시가 진실의 원천」이었다.
A-8 의 volatile 쪽은 롤링 재시작 전에 만들어 둔 세션 하나로 refresh 를 찔러 얻은 값이다. 세션이 노드 메모리에만 있으니 프로세스가 끝나면 함께 사라진다는 설명이 그 뒤에 붙지만, 이 `400` 자체는 탐침 토큰 하나를 확인한 결과이고 여러 사용자의 토큰을 각각 찔러 센 것이 아니다.
A-2 는 방향이 반대다. 세션을 데이터베이스에 써야 하는 쪽에서는 데이터베이스가 멈춰 있는 동안 새 로그인이 `500` 으로 끝나고, 노드 메모리에만 두는 쪽에서는 같은 상황에서 `200` 이 나온다.
## 두 모드가 맞바꾸는 것
앞 절의 세 줄은 실험에서 잰 값이고, 아래 표는 그 측정에서 끌어낸 것이다.
| 무엇이 갈리나 | persistent | volatile |
|---|---|---|
| 재시작 내구성 | 있다 | 없다 |
| 7800 의존 | 낮다 (무효화만) | 높다 (세션 자체) |
| DB 부하 | 로그인·refresh 마다 쓰기 | 세션 관련 없음 |
| 노드 확장 | DB 가 병목 | 복제 트래픽이 N² 로 증가 |
| 지연 민감도 | DB 왕복에 민감 (A-6) | 클러스터 왕복에 민감 |
이 표에서 잰 것은 위 두 줄뿐이다. 재시작 내구성과 7800 의존은 A-8 과 A-1 을 다시 돌려 관측했고, DB 부하 · 노드 확장 · 지연 민감도 세 줄은 이 실험이 재지 않았다. 파드가 둘뿐이라 `` 는 볼 수 없다.
## 이 설명이 닿는 범위
뒤집힌 것은 A-1 · A-2 · A-8 세 건이다. 세 줄 모두 각 실험을 플래그만 바꿔 한 번씩 다시 돌려 얻은 값이고, 같은 조건을 여러 번 반복해 분포를 본 것이 아니다.
A-2 줄에는 조건이 하나 더 붙는다. A-7a 가 같은 상황을 캐시 온도별로 다시 재 보니 volatile 에서 데이터베이스가 멈췄을 때의 결과가 그 노드가 어떤 조회를 이미 캐시했는지에 따라 갈렸고, 완전 냉시동에서는 클라이언트 조회조차 캐시에 없어 로그인이 `400` 이었다.
이 실험대가 본 것은 Keycloak 한 제품의 이 플래그 하나다. 기본값이 메이저 버전 사이에 바뀐 다른 제품에서도 같은 폭으로 결과가 갈리는지는 여기서 재지 않았다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: REFERENCE
slug: state-the-version-and-the-setting-with-the-result
title: 버전과 설정을 결과와 함께 적는다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#코드보다-먼저-드러난-문제-버전-조건
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-적용되지-않는-조건
evidence:
- ../../../final/evidence/raw/a7-volatile-comparison__01-switch-to-volatile.txt
- ../../../final/evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt
- ../../../final/evidence/raw/a7-volatile-comparison__04-a1-rerun-partition.txt
- ../../../final/evidence/raw/a8-rolling-restart__02-session-survival.txt
- ../../../final/evidence/raw/a7-volatile-comparison__03-a8-rerun-restart.txt
- ../../../final/evidence/raw/a2-database-loss__03-four-paths.txt
- ../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt
---
# 버전과 설정을 결과와 함께 적는다
버전과 설정을 빼고 적은 결과는 절반이 틀린 말이 된다. Keycloak 26 의 기본값 하나를 끄자 같은 실험 세 건이 정반대로 뒤집혔다. 이 기준의 근거는 그 사례 하나다.
## 관계
- **persistent-user-sessions 가 세션의 거처를 정한다**
이 기준이 나온 대조다. 같은 실험을 두 설정으로 돌린 결과가 거기 있다.
- **같은 설정이 캐시 온도만으로 세 가지 답을 냈다**
설정 이름을 적은 뒤에도 결과가 다시 갈린 사례다.
## 목적
Keycloak 26 은 persistent-user-sessions 가 기본값이라 세션을 DB 에 쓴다. 그래서 한 노드에서 만든 세션을 다른 노드가 쓸 수 있고, 그렇게 되는 이유는 복제가 아니라 두 노드가 같은 데이터베이스를 보기 때문이다. 24 이전은 그렇지 않아서 메모리에 두고 Infinispan 으로 복제했다.
같은 실험을 --features-disabled=persistent-user-sessions 로 다시 돌리자 세 결과가 정반대로 뒤집혔다. 그래서 「Keycloak 은 이렇다」고 쓸 수 있는 문장이 거의 없다.
제품 이름만 달고 나간 결과는 기본값이 바뀌는 순간 반대 사실을 가리킨다. 이 기준은 결과를 적을 때 그 결과가 어느 버전, 어느 설정에서 나온 것인지를 같이 남겨 두려는 것이다.
## 규칙
### 1. 결과 옆에 제품 버전과 그 결과를 가른 설정 이름을 적는다
Keycloak 26 에서 잰 A층 결론 가운데 셋은 persistent-user-sessions 가 기본이 아닌 환경에서 뒤집힌다. 버전과 설정 이름이 붙어 있지 않으면 그 셋은 제품의 성질처럼 읽힌다.
### 2. 기본값이 바뀌었으면 옛 기본값으로 같은 실험을 다시 돌린다
A-7 과 A-7a 가 그 대조군이다. 26.7.0 에서 옛 기본값을 플래그로 끄고 A층을 다시 돌렸고, A-1 · A-8 · A-2 세 건이 반대 결과를 냈다.
### 3. 재현한 동작을 그 버전에서 잰 값으로 적지 않는다
이 실험대는 Keycloak 24 를 설치해 보지 않았다. 26.7.0 위에서 persistent-user-sessions 기능을 꺼서 24 이전의 기본값 동작을 재현했다. 그래서 volatile 쪽 수치는 24 이전의 기본값과 같은 설정에서 잰 값이고, 24 에서 잰 값이 아니다.
### 4. 뒤집힌 방향을 실험마다 적는다
A-1 의 교차 노드 refresh 와 A-8 의 롤링 재시작 뒤 refresh 는 persistent 에서 200, volatile 에서 400 Session not active 였다. A-2 의 DB 정지 중 새 로그인은 persistent 에서 500, volatile 에서 200 이었다. 세 건의 방향이 같지 않다.
### 5. 같은 설정 안에서 결과가 다시 갈리는지 확인한다
volatile 에서 DB 를 세웠을 때의 동작은 캐시 온도로 갈린다. 설정 이름까지 적고 나서도 그 결과에 걸리는 조건이 더 있는지 본다. 이 규칙은 A-7 이 그 두 값을 한 번 재고 표로 옮겼기 때문에 생겼다. A-7a 가 같은 설정을 캐시 온도 셋으로 갈라 재 보니 A-7 이 적은 것은 그 셋 중 하나였다.
## 적용 조건
- 메이저 버전 사이에 기본값이 바뀐 제품의 동작을 적을 때
- 장애를 주입해 얻은 결과를 문서로 남길 때
- 옛 기본값을 쓰는 환경과 새 기본값을 쓰는 환경을 함께 다룰 때
- 근거의 범위 : 이 기준은 Keycloak 26 의 persistent-user-sessions 사례 하나에서 나왔다. 실험은 A-1 · A-2 · A-8 세 건이 전부다
## 예외
- 측정 대상이 그 설정에 걸리지 않는 경로라면 조건을 달지 않아도 된다. 무엇이 걸리는지를 먼저 확인한다
- 다른 제품의 기본값 변경에서도 같은 일이 나는지는 이 프로젝트가 재지 않았다. 이 기록을 그 근거로 쓰지 않는다
- 이 기준이 말하는 것은 무엇을 적는가이지 어느 설정이 나은가가 아니다. 두 설정의 우열은 여기서 재지 않았다
## 예시
- A-1 · 7800 차단 후 교차 노드 refresh : persistent(26 기본) 200 · volatile(24 이전) 400 Session not active
- A-8 · 롤링 재시작 후 refresh : persistent 200 · volatile 400 Session not active
- A-2 · DB 정지 중 새 로그인 : persistent 500 · volatile 200
- 쓰지 않는다 : Keycloak 은 다중 노드에서 세션을 공유한다
- 쓴다 : Keycloak 26 은 persistent-user-sessions 가 기본이라 두 노드가 같은 데이터베이스를 읽고 같은 세션을 본다
- 쓴다 : 위 결과는 26.7.0 에서 그 기능을 꺼서 재현한 것이고 24 에서 잰 값이 아니다
@@ -0,0 +1,846 @@
---
id: 4f56ed58-fc82-4ed1-ac87-c356b30c34f7
kind: SETUP
slug: reproduce-a0-session-sharing-path
title: 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/4f56ed58-fc82-4ed1-ac87-c356b30c34f7/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-0
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다
세션이 Infinispan 복제로 공유되는지 두 노드가 같은 PostgreSQL 을 읽어서 공유되는지를 손으로 가르는 절차다. 세션 테이블을 비우고 재시작해 0 에서 출발한 뒤, 상주 탐침 파드에서 시험 넷을 차례로 친다. 전 구간 약 40분이고 A층 뒤의 아홉 편이 이 결과 위에 선다.
## 관계
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
이 절차가 낸 결론을 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
- **persistent-user-sessions 가 세션의 거처를 정한다**
이 절차의 모든 숫자가 그 설정이 켜진 상태에서 나온다. 끄면 같은 명령이 다른 답을 낸다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
시험 0 에서 반대편 노드의 응답만 재고 발급 노드에 같은 요청을 안 보내면 `403` 을 복제 실패로 읽는다. 그 규칙을 편 기록이다.
- **7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다**
여기서 잰 교차 노드 refresh `200` 과 로그아웃 뒤 `400` 이 그 편의 대조군 값이 된다.
- **PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다**
시험 0d 에서 잡은 `SET LOCAL synchronous_commit TO OFF` 한 줄의 대가를 그 편이 건수로 잰다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 `kubectl``psql` 로 친다. 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl``sudo` 를 붙이지 않는다.
```bash label="[kc-lab-1] sudo 를 붙이는 쪽이 틀린 형태다"
kubectl -n keycloak-lab get pods # 이렇게
sudo kubectl -n keycloak-lab get pods # 이렇게 치면 안 된다
```
`sudo` 를 붙이면 root 환경으로 돌아 사용자 홈의 kubeconfig 를 못 본다. root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 막힌 곳은 클러스터가 아니라 `kubectl` 이 어느 설정 파일을 읽느냐다.
터미널은 둘을 연다. 하나는 탐침 파드 셸용이라 붙잡혀 있고, 하나는 관찰용이다. 그래서 `[kc-lab-1]` 라벨이 붙은 블록이 `[탐침 파드]` 블록 사이에 끼어 있으면 **관찰용 터미널에서 친다** — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 `exit` 를 블록으로 따로 적는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
| 탐침 파드 | `kc-probe` — `curlimages/curl:8.11.1`, `--rm -it`, `--restart=Never` |
| 주 계기 | `vendor_statistics_approximate_entries_unique` 와 PostgreSQL 문장 로그 |
| 스크레이프 간격 | 15초. 지표를 다시 묻기 전에 30초 기다린다 |
| 걸리는 시간 | 전 구간 약 40분 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 |
## 이 실험이 가르는 것
앞 단계에서 Keycloak 2노드 클러스터를 세우고 로그에서 `ISPN000094` 멤버 2개를 확인했다면 아는 것은 「클러스터가 떴다」까지다. 그 위에 장애를 주입해도 무엇이 무엇 때문에 깨졌는지 해석할 수 없다.
갈라야 할 것은 둘이다.
```text
두 노드가 같은 답을 한다
├── (a) Infinispan 이 세션을 복제했다 ← 통념
└── (b) 두 노드가 같은 PostgreSQL 을 본다 ← 확인할 것
```
「반대편에서도 된다」만 보면 (a) 와 (b) 의 결과가 같아서 구별이 안 된다. 그래서 시험을 넷으로 나눈다.
| 시험 | 무엇을 가르나 |
|---|---|
| **0** 교차 노드 사용 | 반대편이 그 세션을 쓸 수 있는가 (여기까지는 (a)·(b) 구별 안 됨) |
| **0b** 캐시 계수기 델타 | 로그인 하나에 반대편 캐시가 **움직이는가** |
| **0c** 엔트리 소유 | 엔트리가 **어느 노드에** 생기는가 |
| **0d** SQL 포획 | 반대편이 **정말 DB 를 읽는가** — 추론을 관측으로 바꾼다 |
절차를 끝까지 밟으면 한 노드에서 만든 세션을 반대편이 갱신하는 것, 반대편에서 로그아웃하면 원래 노드가 `400` 을 주는 것, 로그인을 받은 노드의 캐시만 늘고 반대편은 `+0` 인 것, 캐시 합계와 DB 총계가 `7 + 5 = 12` 로 맞는 것, 반대편 노드가 날린 `SELECT`·`UPDATE` 문장, 그 트랜잭션 안의 `SET LOCAL synchronous_commit TO OFF` 를 자기 화면에서 보게 된다.
## 전제와 되돌리기
- `05-keycloak` · `06-observability` 가 끝나 있다.
- 두 Keycloak 파드가 서로 다른 노드에 있어야 한다. 같은 노드면 이 실험이 성립하지 않는다.
- 게스트 셸이 따로 필요한 것은 `nft`·`tc`·`systemctl` 처럼 노드 자체를 건드리는 명령뿐이고 이 편에는 그런 명령이 없다.
**이건 상태를 부수는 실험이다.** 세션 테이블을 비우고, StatefulSet 을 재시작하고, PostgreSQL 의 문장 로깅을 켠다. **실험대에서만 한다.** 지운 세션은 돌아오지 않는다. 되돌릴 수 있는 것은 문장 로깅 하나이고, 켜기 전에 끄는 명령을 먼저 읽어 둔다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 묶음"
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()"
```
## 주입 전에 같은 명령으로 먼저 본다
넓은 것부터 좁혀 간다.
```text
노드 → 파드 → 클러스터 뷰(로그) → 디스커버리(DB) → DB 세션 수 → 노드별 캐시 → 탐침 고르기
```
### 1. 두 파드가 서로 다른 노드에 있는가
**무엇을 보는가** — 파드가 둘 다 Ready 이고 다른 기계에 나뉘어 있는지.
```bash label="[kc-lab-1] ① 노드를 본다"
kubectl get nodes
```
```bash label="[kc-lab-1] ② 파드가 어느 노드에 있는지 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`, 그리고 `NODE` 열이 서로 다른지. `postgres` 가 어느 노드에 있는지도 적어 둔다.
**이 값이 뜻하는 것** — 두 Keycloak 파드가 같은 노드에 있으면 이 실험은 성립하지 않는다. 원래 실행에서는 `keycloak-0` 이 `kc-lab-2`, `keycloak-1` 이 `kc-lab-1` 이었다(observed). 파드 번호와 노드 번호가 어긋나므로 이름만 보고 짐작하지 않는다. `postgres` 의 위치는 A-2 와 A-3 에서 쓴다.
### 2. 파드 IP 두 개를 변수에 담는다
**무엇을 보는가** — 뒤의 모든 요청이 향할 주소.
```bash label="[kc-lab-1] 파드 IP 를 변수에 담고 눈으로 확인한다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**어디를 보나** — 두 값이 빈 문자열이 아닌지.
**이 값이 뜻하는 것** — 실측은 이렇게 나왔다(observed, `01-cross-node-session.txt`).
```text
=== 대상 ===
keycloak-0 10.42.1.43 kc-lab-2
keycloak-1 10.42.0.35 kc-lab-1
```
### 3. 클러스터 뷰를 로그에서 읽는다
**무엇을 보는가** — 두 노드가 서로를 멤버로 세고 있는지.
```bash label="[kc-lab-1] 양쪽 로그에서 마지막 클러스터 뷰 한 줄씩"
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
```
**어디를 보나** — 괄호 안의 멤버 수. 실측은 이렇다(observed).
```text
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)]
```
한 줄을 토막으로 끊으면 이렇게 읽힌다.
```text
[keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
└── 코디네이터 ──┘ │ │ └────── 멤버 목록 ──────┘
│ └─ 멤버 수
└─ 뷰 ID (바뀔 때마다 1 증가)
```
**이 값이 뜻하는 것** — 멤버가 2 다. 이 줄이 증명하는 범위는 거기까지이고, 멤버가 둘이라는 것과 세션이 오간다는 것은 다른 말이다.
### 4. 디스커버리 테이블을 본다
**무엇을 보는가** — 노드가 서로를 찾는 길인 `JGROUPS_PING` 테이블에 무엇이 등록돼 있는지.
```bash label="[kc-lab-1] 디스커버리 테이블 세 열"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
**어디를 보나** — `coord` 열에 `t` 가 정확히 하나인지. 실측은 이렇다(observed).
```text
name | ip | coord
------------------+-----------------+-------
keycloak-1-48749 | 10.42.0.35:7800 | t
keycloak-0-30843 | 10.42.1.43:7800 | f
(2 rows)
```
**이 값이 뜻하는 것** — 이 테이블은 지금 등록되어 있다는 것만 말한다. 로그는 그때 그렇게 보였다는 기록이고 둘은 다른 것을 말한다.
### 5. 세션이 사는 테이블을 확인한다
**무엇을 보는가** — 온라인 세션이 어느 테이블에 들어가는지.
```bash label="[kc-lab-1] 테이블 목록"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c "\dt"
```
**어디를 보나** — `USER_SESSION` 이라는 이름이 목록에 없는 것. 실측은 이렇다(observed).
```text
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
```
**이 값이 뜻하는 것** — `persistent-user-sessions`(Keycloak 26 기본값)는 새 테이블을 만들지 않고 기존 오프라인 세션 테이블을 재사용한다. `offline_flag` 컬럼으로 구분하고 `'0'` 이 일반 로그인, `'1'` 이 `offline_access` 다. 기본키가 `(user_session_id, offline_flag)` 복합키인 까닭이 여기 있고, 이 절차의 모든 질의는 `offline_flag='0'` 이다.
```bash label="[kc-lab-1] 지금 몇 건인지 센다"
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"
```
### 6. 노드별 캐시 엔트리를 밖에서 묻는다
**무엇을 보는가** — 이 실험의 주 계기인 캐시 엔트리 수.
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어 `exec` 로 물으면 `exit 127` 이 난다. Prometheus 가 15초마다 이미 긁고 있으므로 밖에서 묻는 쪽이 짧다.
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'
```
처음 한 번은 자르지 않고 그대로 본다. 어떤 라벨이 붙어 있는지 알아야 다음부터 무엇으로 거를지 정할 수 있다. 라벨을 보고 나면 읽기 좋게 자른다 — 아래 줄은 가이드가 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ② 라벨을 보고 나서 필요한 줄만 자른다"
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` 이 싫어할 수 있어 쿼리에 라벨 필터를 걸지 않고 받은 뒤에 거른다.
### 7. 탐침을 무엇으로 할지 정한다
**무엇을 보는가** — 어떤 요청을 보내야 세션 공유를 재는 것이 되는지.
첫 판본은 `userinfo` 로 쟀고 `http_code=403` 을 복제 실패로 읽을 뻔했다. 발급 노드에 같은 요청을 나란히 보내 보니 이랬다(observed).
```text
--- 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 token 은 회전한다. 한 번 쓰면 옛 것이 무효가 되므로 반대편 노드에 먼저 써야 하고, 발급 노드에 먼저 쓰면 시험군에 쓸 토큰이 사라진다.
판정은 세션 개수가 아니라 sid 로 한다. 관리 API 의 `active=2` 를 보고 판정하려던 첫 시도는 스크립트 자체가 로그인을 두 번 했기 때문에(시험용 + 관리 API 호출용) 실패했다. 같은 문자열이 세 곳에 나온다.
```text
JWT access_token 의 sid jiv3rVZi1VeaO07oVJkL_MYW
↕ 같은 값
DB user_session_id jiv3rVZi1VeaO07oVJkL_MYW
↕ 같은 값
Admin API 세션 목록의 id jiv3rVZi1VeaO07oVJkL_MYW
```
## 주입
주입은 둘이다. 첫째는 출발값을 0 으로 만드는 것이고, 둘째는 시험 0d 직전에 몇 초만 켜는 문장 로깅이다.
### 1. 세션 테이블을 비우고 StatefulSet 을 재시작한다
**목적** — DB 행과 캐시 엔트리를 동시에 0 으로 만들어, 뒤에 세는 숫자가 이 실험이 만든 것만 담게 한다.
```bash label="[kc-lab-1] ① 온라인·오프라인 세션 행을 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
```bash label="[kc-lab-1] ② 재시작 시각을 남기고 롤아웃을 건다"
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
```
```bash label="[kc-lab-1] ③ 새 파드가 다 설 때까지 블록한다"
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**예상 결과** — ③ 이 돌아오면 두 파드가 새로 떠 있다. 세션이 전부 지워지고 두 파드가 재시작된 상태이며 되돌릴 수 없다.
**왜 필요한가** — DB 만 지우면 캐시 엔트리가 그대로 있어 출발값이 어긋난다. 원래 실행에서 정리하려고 `delete from offline_user_session` 만 했더니 캐시 합계 19 와 DB 총계 15 가 맞지 않았다(observed). ② 의 시각은 나중에 Grafana 로 시계열을 볼 때 캐시가 0 으로 떨어진 절벽을 찾는 데 쓴다.
**문제가 생기면** — ③ 이 타임아웃으로 끝나면 파드 목록부터 보고, 파드가 안 뜨면 앞 단계인 `05-keycloak` 로 돌아간다.
### 2. PostgreSQL 문장 로깅 — 여기서 켜지 않는다
**목적** — 둘째 주입의 자리를 밝혀 둔다. 명령은 「관찰」의 시험 0d 안에 있다.
문장 로깅은 몇 초만 켠다. 여기서 켜면 시험 0·0b·0c 를 켠 채로 돌게 되고, 그 셋은 로그인과 refresh 를 수십 번 보내므로 로그가 폭주해 시험 0d 에서 찾아야 할 열두 줄이 묻힌다. 켜는 명령과 그 검증은 시험 0d 의 첫 두 단계다.
## 주입 검증
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 본다.
### 파드가 새로 떴고 IP 가 바뀌었는가
```bash label="[kc-lab-1] 새 파드와 새 IP 를 다시 잡는다"
kubectl -n keycloak-lab get pods -o wide | grep keycloak
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
`AGE` 가 방금이고 `RESTARTS` 가 `0`(새 파드다), 그리고 IP 가 아까 적어 둔 값과 다른지 본다. IP 를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿고, 이 상태를 복제 실패로 읽는 실수가 이 실험에서 가장 흔하다.
### DB 에 세션이 한 행도 없는가
```bash label="[kc-lab-1] 남은 행을 센다"
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"
```
한 행도 없어야 한다. 행이 남아 있으면 `delete` 가 실패했거나 그 사이 누가 로그인했다.
### 캐시가 양쪽 다 0 인가
앞의 미검증 형태(`tr`·`grep` 줄)를 다시 치고 `cache":"sessions"` 인 두 줄이 다 `0` 인지 본다. 한쪽만 확인하고 넘어가면 원래 있던 값을 나중에 복제가 왔다고 읽는다. Prometheus 는 15초마다 긁으므로 재시작 직후에 물으면 옛 값이 나올 수 있어 30초쯤 기다렸다가 다시 친다.
## 관찰
상주 탐침 파드를 띄운다. Keycloak 이미지에 `curl` 이 없고, 토큰을 단계 사이로 넘겨야 하며, Service 로 보내면 어느 노드가 처리했는지 알 수 없다. 이 실험의 질문 자체가 어느 노드인가이므로 파드 IP 로 직접 친다.
```bash label="[kc-lab-1] 탐침 파드를 띄우고 그 안의 셸로 들어간다"
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=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
셸에서 `exit` 하면 `--rm` 이 파드를 지운다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 밖에서 확인한다.
```bash label="[kc-lab-1] 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
실측은 `19` 다(observed). 파드 안에서도 값이 들어왔는지 길이로만 본다.
```sh label="[탐침 파드] 환경변수가 들어왔는지 길이로 본다"
echo "K0=$K0 K1=$K1 PW길이=${#PW}"
```
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이므로 나가서 다시 띄운다.
### 시험 0 — 반대편 노드가 그 세션을 쓸 수 있는가
`keycloak-0` 에서 로그인하고 응답을 한 번 통째로 본다.
```sh label="[탐침 파드] ① 발급 노드에 로그인하고 응답을 그대로 본다"
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"
```
`expires_in` 과 `refresh_expires_in` 을 본다. 실측은 이렇다(observed, `01-cross-node-session.txt`).
```text
=== [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 이면 안 된다. `sub` 이 없는 것은 `admin-cli` 에 `scope` 없이 direct grant 를 하면 클레임이 `azp, exp, iat, iss, jti, scope, sid, typ` 뿐이기 때문이고(observed), 앞의 `userinfo` 403 과 원인이 같다.
```sh label="[탐침 파드] ② 토큰을 변수에 담고 길이로만 확인한다"
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 만 잘라낸다. 두 줄 다 미검증이다(unknown).
```sh label="[탐침 파드] ③ 클레임을 통째로 디코드해 본다"
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
```sh label="[탐침 파드] ④ sid 만 뽑는다"
SID=$(echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "SID=$SID"
```
base64 패딩 때문에 끝이 깨져 보일 수 있고(`2>/dev/null` 이 그 불평을 지운다), `sid` 는 앞쪽에 있어서 대개 보인다.
같은 sid 가 두 노드 모두에서 보이는지 물으려면 `admin-cli` 의 내부 id 가 필요하다. 응답을 한 번 그대로 보고 무엇을 자르는지 눈으로 본 다음 잘라낸다. 잘라내는 줄은 미검증이다(unknown).
```sh label="[탐침 파드] ⑤ 클라이언트 목록 응답을 그대로 본다"
curl -s -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
```
```sh label="[탐침 파드] ⑥ 첫 번째 id 만 잘라낸다"
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` 은 첫 번째 것을 고르므로 안전하다.
```sh label="[탐침 파드] ⑦ 같은 질문을 두 노드에 던진다"
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
```
이 `for` 루프도 미검증이다(unknown). 실측은 이렇다(observed, `01-cross-node-session.txt`).
```text
=== [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
```
세션이 2개인 것은 실험 도구가 만든 잡음이고 판정에 안 쓴다. 여기까지는 (a) 와 (b) 를 구별하지 못한다.
시험군은 회전 때문에 반대편에 먼저 쓴다.
```sh label="[탐침 파드] ⑧ 반대편 노드에서 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')
```
`200`, 그리고 새 토큰의 sid 가 같은 값이어야 한다. sid 가 바뀌었다면 세션을 이어받지 않고 새로 만들었다는 뜻이다. 매번 `RT` 를 다시 담는다 — 옛 것을 계속 쓰면 나중에 나오는 400 이 무효화 때문인지 재사용 때문인지 알 수 없게 된다.
무효화가 반대 방향으로도 가는지 본다.
```sh label="[탐침 파드] ⑨ 반대편에서 로그아웃하고 발급 노드에서 다시 갱신해 본다"
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"
```
실측은 이렇다(observed).
```text
=== [6] keycloak-1 을 통해 로그아웃 ===
http_code=204
=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
HTTP 400 ← 기대대로
error invalid_grant
error_description Session not active
```
이 `400` 을 적어 둔다. A-1 에서 7800 을 막으면 같은 곳이 `200` 으로 바뀌고, 그것이 A-1 의 결론이다.
### 시험 0b — 복제인가, 같은 DB 를 본 것인가
로그인 한 번을 사이에 두고 양쪽 노드의 캐시 계수기를 잰다. 복제라면 반대편도 같이 늘고, 같은 DB 를 보는 것뿐이라면 반대편은 안 움직인다. 전값을 재고, `keycloak-0` 에만 로그인 한 번을 넣고, 30초 기다렸다가 후값을 같은 명령으로 잰다.
```sh label="[탐침 파드] 발급 노드에만 로그인 한 번을 넣고 나간다"
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
```
실측은 이렇다(observed, `02-cache-delta.txt`).
```text
=== 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 이다. `rpc.replication_count` 가 `1`·`7` 로 0 이 아닌 것에 속으면 안 된다 — 이 계수기는 세션 캐시만의 것이 아니라 클러스터가 다른 용무로 주고받은 것까지 센다. 판정은 증가분이 0 이라는 것으로 한다.
### 시험 0c — 엔트리는 어느 노드에 있는가
반대편 노드에 로그인을 몰아주면 분산 캐시(owners=1)와 로컬 캐시가 갈린다.
0b 에서 `exit` 했으므로 `--rm` 이 탐침 파드를 이미 지웠다. 0c 와 0d 는 파드 안에서 치므로 같은 명령으로 다시 띄운다.
```bash label="[kc-lab-1] 탐침 파드를 다시 띄운다"
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=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
```sh label="[탐침 파드] ① 반대편 노드에 로그인 5회를 몰아준다"
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
```
30초 기다렸다가 관찰용 터미널에서 엔트리를 잰다. 「주입 전에」 §6 의 `tr`·`grep` 형태를 그대로 쓰고, `cache":"sessions"` 인 두 줄의 값을 적어 둔다. 스크레이프 간격이 15초라 바로 물으면 옛 값이 나온다.
그다음 같은 루프를 `$K1` 만 `$K0` 로 바꿔 한 번 더 친다. 원 가이드는 이 두 번째 루프를 「`$K1` 을 `$K0` 로 바꿔 5회 더」라고 문장으로만 적었다.
```sh label="[탐침 파드] ② 이번엔 발급 노드에 5회를 몰아준다"
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
```
30초 기다렸다 같은 방법으로 다시 잰다. 실측은 이렇다(observed, `03-cache-ownership.txt`).
```text
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
```
대각선이다. 한 번에 한 쪽만 늘고, `7 + 5 = 12` 로 DB 총계와 맞으므로 어느 엔트리도 두 번 세어지지 않았다.
```bash label="[kc-lab-1] DB 총계를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
캐시 설정은 파일에서 읽을 수 없다. 파드의 `/opt/keycloak/conf/cache-ispn.xml` 은 `<cache-container name="keycloak"><transport/></cache-container>` 뿐이고 Keycloak 26 은 캐시를 코드에서 만든다. 위 결론은 설정을 읽어서가 아니라 동작을 측정해서 얻었다.
### 시험 0d — SQL 을 직접 잡는다
0b·0c 까지는 추론이다. 여기서 둘째 주입인 문장 로깅을 켠다. 앞의 세 시험이 끝난 지금 켜는 것이고, 시험 0d 가 끝나면 이 절의 마지막에서 곧바로 끈다.
```bash label="[kc-lab-1] ① 문장 로깅과 클라이언트 주소 접두사를 켜고 같은 명령에서 reload 한다"
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()"
```
`pg_reload_conf` 가 `t` 를 돌려준다. `%h` 가 클라이언트 주소를 로그 줄 앞에 남기는데, 이것이 없으면 어느 파드가 보낸 질의인지 구별할 수 없어 이 시험의 판정이 성립하지 않는다.
```bash label="[kc-lab-1] ② 두 설정이 실제로 적용됐는지 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement" -c "show log_line_prefix"
```
실측은 이렇다(observed, `04-read-path-sql.txt`).
```text
log_statement = all
log_line_prefix = %m [%p] %h
```
`log_statement` 가 아직 `none` 이면 `alter system` 이 `postgresql.auto.conf` 에 쓰기만 하고 `pg_reload_conf()` 가 안 돈 상태다.
이제 요청을 딱 한 번 보낸다. 여러 번 보내면 로그에서 어느 트랜잭션이 어느 요청인지 구별하기 어려워진다.
```sh label="[탐침 파드] ③ 로그인 한 번 · 반대편에서 refresh 한 번"
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"
```
실측은 이렇다(observed).
```text
=== 요청 ===
SID=jSt9GEPVQLJsO-1CeJjVgltg
K1_ENTRIES_BEFORE=5.0
REFRESH_ON_K1=200
K1_ENTRIES_AFTER=5.0
```
`%h` 가 남긴 IP 로 걸러 `keycloak-1` 이 보낸 것만 본다. 거르는 명령은 관찰용 터미널에서 치는데 거기에는 `K0`·`K1` 이 없다. 두 값은 「주입 검증」에서 잡았는데 그 터미널을 지금 탐침 파드 셸이 붙잡고 있으므로, 관찰용 터미널에서 두 줄을 다시 친다.
```bash label="[kc-lab-1] 관찰용 터미널에서도 파드 IP 를 잡는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
두 값이 「주입 검증」에서 본 것과 같아야 한다. 이 두 줄을 건너뛰고 다음 블록을 치면 `grep "$K1"` 이 `grep ""` 가 되어 모든 줄이 통과하므로, 두 파드가 날린 문장을 `keycloak-1` 만 걸러 낸 것으로 읽게 되고 뒤에서 세는 건수도 양쪽이 같은 값으로 나온다. 화면에는 아무 경고도 안 뜬다.
```bash label="[kc-lab-1] 반대편 노드가 날린 문장만 추린다"
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep "$K1" | grep 'LOG: execute'
```
실측은 이렇다(observed, `04-read-path-sql.txt`).
```text
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 에 쓴다. 파라미터는 `DETAIL` 줄에 있다. sid 는 ③ 이 `SID=` 로 화면에 찍은 값을 옮겨 넣는다 — 그 변수는 탐침 파드 안에만 있어서 `[kc-lab-1]` 셸에서는 `$SID` 가 빈 문자열이다. 로그인할 때마다 새로 생기는 값이기도 하다.
```bash label="[kc-lab-1] 그 sid 가 들어간 줄만 앞에서 120자씩 본다"
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep '{{SID}}' | cut -c1-120
```
이 실험대의 값은 `jSt9GEPVQLJsO-1CeJjVgltg` 였다(observed).
실측은 이렇다(observed).
```text
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 가 그대로 나온다. pid 도 본다 — `81407` 은 `keycloak-0` 의 연결, `81376` 은 `keycloak-1` 의 연결이며 pid 가 트랜잭션의 경계를 가른다.
이 갱신 트랜잭션은 `01:12:34.934` 의 `BEGIN` 에서 `01:12:34.947` 의 `COMMIT` 까지 13밀리초다. `BEGIN` 과 `COMMIT` 은 sid 를 파라미터로 달지 않아서 위 `grep` 에 안 걸린다. 그래서 화면에 남는 마지막 줄이 `.946` 이고, 거기까지만 세면 12 가 나온다. 경계 두 줄은 같은 pid `81376` 연결에서 나왔고, 실험 기록에 `pid=… |` 꼴로 옮겨 적힌 것으로만 남아 있다(observed). 자기 화면에서 그 둘을 보려면 sid 필터를 빼고 로그에 찍힌 그 pid 로 다시 걸러야 하는데, 그 명령은 원 가이드에 없다(unknown). 그 두 줄이 하는 일은 트랜잭션의 경계를 긋는 것이다. 뒤에 나오는 `SET LOCAL synchronous_commit TO OFF` 가 같은 트랜잭션 안에서 `COMMIT` 직전에 나왔다는 판정이 거기서 나오고, 원 가이드는 그 확인을 「pid 로 경계를 확인했다」로 적는다.
파드별 질의 건수는 미검증 형태로 센다(unknown). 여기서도 sid 는 ③ 이 찍은 자기 값이다.
```bash label="[kc-lab-1] 두 파드가 각각 몇 줄을 날렸는지 센다"
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep '{{SID}}' | grep -c "$K0"
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep '{{SID}}' | grep -c "$K1"
```
실측은 `6 [keycloak-1]` 과 `6 [keycloak-0]` 이다(observed). sid 하나에 대해 `keycloak-0` 이 6건(로그인), `keycloak-1` 이 6건(갱신)을 날렸다.
위 실측의 `K1_ENTRIES_BEFORE=5.0` 과 `K1_ENTRIES_AFTER=5.0` 이 같다. `keycloak-1` 은 남의 세션을 DB 에서 읽어 처리하고도 캐시에 담지 않았다. 캐시에 담기는 것은 그 노드가 로그인시켜 만든 세션뿐이고 남의 세션은 매번 DB 에서 읽는다. 세션 어피니티가 정확성이 아니라 성능 문제인 까닭이 여기 있다.
같은 로그에 jdbc-ping 하트비트도 보인다(observed).
```text
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 이 그 둘을 갈라 끊는다.
13밀리초짜리 그 트랜잭션에는 셋이 들어 있었다. 낙관적 락(`VERSION` 컬럼), `FOR NO KEY UPDATE ... SKIP LOCKED`, 그리고 `SET LOCAL synchronous_commit TO OFF` 다. 전역 설정은 다르다.
```bash label="[kc-lab-1] 전역 설정을 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show synchronous_commit"
```
전역은 `on` 이고 Keycloak 이 세션 트랜잭션에만 `SET LOCAL` 로 끈다. `SET LOCAL` 은 그 트랜잭션이 끝나면 되돌아간다. PostgreSQL 이 갑자기 죽으면 직전 수백 밀리초의 세션 쓰기가 사라질 수 있고, A-3 이 그 숫자를 잰다.
여기까지가 탐침 파드에서 칠 것의 마지막이다. 파드 셸을 붙잡고 있던 터미널에서 나온다. 나가지 않으면 `--rm` 이 파드를 안 지우고 원상복구 확인표의 `kc-probe` 줄이 `NotFound` 가 아니게 된다.
```sh label="[탐침 파드] 나온다. --rm 이 파드를 지운다"
exit
```
문장 로깅은 곧바로 끈다.
```bash label="[kc-lab-1] 문장 로깅을 끄고 꺼졌는지 읽는다"
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()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
켜 둔 채로 다음 실험에 들어가면 안 된다. 로그인 루프를 도는 A-3 에서 `log_statement='all'` 을 켜 두면 로그가 폭주한다.
## 복구와 원상복구 확인표
이 실험은 세션을 만들 뿐 클러스터를 부수지 않는다. 되돌릴 것은 둘이다.
### 1. 문장 로깅을 끈 상태로 되돌린다
**목적** — 다음 실험이 옛 설정 위에서 돌지 않게 한다.
```bash label="[kc-lab-1] ① 두 설정을 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement" -c "show log_line_prefix"
```
```bash label="[kc-lab-1] ② none 이 아니면 두 설정을 되돌리고 reload 한다"
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()"
```
**예상 결과** — `log_statement` 가 `none` 이다.
**왜 필요한가** — A-3 은 초당 14건으로 로그인을 도는데 문장 로깅이 켜져 있으면 로그가 폭주하고 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.
**문제가 생기면** — `pg_reload_conf()` 를 다시 친다. `alter system` 만으로는 적용되지 않는다.
### 2. 실험이 만든 세션을 정리한다
**목적** — DB 행과 캐시 엔트리를 함께 비워 다음 실험의 출발값을 0 으로 만든다.
```bash label="[kc-lab-1] ① 세션 행을 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
```bash label="[kc-lab-1] ② 파드를 갈아 끼워 캐시를 비운다"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**예상 결과** — 두 파드가 새로 뜨고 세션 캐시가 양쪽 다 0 이 된다.
**왜 필요한가** — 재시작을 빼면 DB 만 비워지고 캐시가 남아 다음 실험의 출발값이 어긋난다.
**문제가 생기면** — 아래 확인표의 항목을 위에서부터 하나씩 친다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| 클러스터 뷰 | `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` |
| 탐침 파드 | `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` |
탐침 파드가 지워지지 않았으면 직접 지운다.
```bash label="[kc-lab-1] --rm 이 안 먹었을 때"
kubectl -n keycloak-lab delete pod kc-probe --ignore-not-found
```
## 막히면
아래는 전부 이 실험대가 실제로 겪은 증상이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 띄우거나 Prometheus 에 묻는다 |
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 를 다시 |
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | 파드 안에서 `echo ${#PW}` — `0` 이면 `--env` 가 빈 값 |
| 반대편 응답만 보고 「복제 실패」로 읽었다 | **대조군이 없다** | 발급 노드에 같은 요청을 나란히 |
| `userinfo` 가 양쪽 다 `403` | **`openid` scope 가 없다.** 복제와 무관 | 본문의 `insufficient_scope` |
| 세션 개수가 계속 어긋난다 | **관리 API 호출도 세션을 만든다** | 개수 말고 **sid** 로 본다 |
| 캐시 합계와 DB 총계가 안 맞는다 | **DB 만 지우고 파드를 재시작 안 했다** | `rollout restart statefulset/keycloak` |
| 로그인했는데 지표가 안 움직인다 | Prometheus 스크레이프는 15초 간격 | 30초 기다렸다 다시 |
| `rpc.replication_count` 가 0 이 아니라 당황 | 세션 캐시만의 계수기가 아니다 | 절대값이 아니라 **증가분**으로 본다 |
| `CID` 가 엉뚱한 값이다 | `sed` 의 `.*` 가 탐욕적이라 **마지막** `"id"` 를 잡는다 | `tr ',' '\n' \| grep -m1 '"id"'` |
| 문장 로깅을 켰는데 SQL 이 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 |
| 로그에 어느 파드인지 안 나온다 | `log_line_prefix` 에 `%h` 가 없다 | `show log_line_prefix` |
| 다음 실험에서 postgres 로그가 폭주한다 | **문장 로깅을 껐는지 확인 안 했다** | `show log_statement` 가 `none` |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 09:5210:14 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 파드 IP `10.42.1.43`·`10.42.0.35`, 뷰 ID `5` 와 멤버 `(2)`, `jgroups_ping` 의 `coord = t` 하나, 캐시 델타 전량, `7 + 5 = 12`, `keycloak-1` 이 날린 SQL 여덟 줄, pid `81407`/`81376`, 비밀번호 길이 `19`.
- (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력, JWT 를 디코드해 sid 를 뽑는 `sed` 줄, `CID` 를 뽑는 줄, 두 노드에 `grep -c` 를 도는 `for` 루프, 파드별 질의 건수를 세는 두 줄. 가이드가 미검증으로 표시했고 원래 실행은 스크립트로 했다.
- 트랜잭션 경계 시각 `01:12:34.934` 와 `01:12:34.947` 은 실험 기록 한 벌에만 있다. `04-read-path-sql.txt` 에 보존된 것은 sid 가 걸린 `DETAIL` 줄과 시각 없는 문장 목록이라 타임스탬프가 붙은 `BEGIN`/`COMMIT` 이 없다. 그래서 13밀리초를 증거 원문으로 다시 확인할 수는 없다.
- 캐시 설정은 파일에서 읽을 수 없다. `cache-ispn.xml` 에는 `<transport/>` 뿐이고, 「세션은 DB 로 공유된다」는 판정은 설정을 읽어서가 아니라 동작을 측정해서 얻었다.
- 스크립트를 안 쓰는 까닭도 측정 실패에서 나왔다(observed). `kubectl run --rm -i ... | grep` 로 받았더니 중간 조각이 통째로 사라져 `keycloak-1` 의 스냅샷과 다음 마커가 함께 없어졌고, 전값이 0 으로 잡히면서 가짜 델타가 만들어졌다. 그때 리포트는 `keycloak-1` 이 `+9`, `+7` 증가한 것처럼 보였다 — 없는 복제가 있는 것처럼 보이는 오류다. 다른 하나는 중첩 인용이다. `ssh host '... $VAR ...'` 안에 다시 `sh -c "..."` 를 넣으면 인용이 세 겹이 되어 치환이 조용히 깨졌고, 첫 시도에서 파드 IP 가 빈 문자열이 되어 아무 출력도 나오지 않았다.
<!-- body:end -->
@@ -0,0 +1,819 @@
---
id: 95e29500-b535-4246-86db-d569ed814904
kind: SETUP
slug: reproduce-a1-jgroups-transport-block
title: 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/95e29500-b535-4246-86db-d569ed814904/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-1
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다
NetworkPolicy 로 8080 과 9000 만 열어 JGroups 트랜스포트인 TCP 7800 만 끊는 절차다. 그러고도 25분 동안 클러스터가 안 깨지는 것을 보고 conntrack 표에서 까닭을 찾은 뒤, 정책이 걸린 채로 파드를 지워 분단을 만든다. 전 구간 약 30분.
## 관계
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
이 절차가 낸 결론을 담은 기록이다. 여기에는 치는 순서만 있다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
정책을 걸었는데 `vendor_cluster_size` 가 25분 내내 2 였던 것이 그 아홉 건 중 하나다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
분단된 노드가 스스로 Service 에서 빠져 정문이 `200` 을 유지한 까닭을 다룬다.
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
이 편의 원래 실행이 빈 문자열을 「변화」로 읽고 빠져나온 판정 조건을 담고 있다.
- **세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다**
이 편의 대조군 값(`200`·`400`)이 거기서 나온다. 먼저 해 두지 않으면 차단 후의 `200` 이 무엇과 다른지 알 수 없다.
- **한 방향만 끊어 보고 raw PREROUTING 까지 내려간다**
같은 7800 을 `iptables` 로 한 방향만 끊는 편이고, conntrack 으로 연결 방향을 먼저 보는 순서를 그쪽에서 다시 쓴다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
`kubectl``[kc-lab-1]` 에서 친다. `conntrack` 은 노드 자체를 건드리는 명령이라 게스트 셸이 필요하고 두 노드 모두에서 봐야 한다 — 이쪽 노드는 그대로 치고 반대 노드는 `ssh kc-lab-2` 로 붙어서 친다. `kubectl` 에는 `sudo` 를 붙이지 않는다. root 홈에 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 코드블록마다 어느 셸인지 붙여 두었다.
주입에 쓰는 매니페스트 경로 `deploy/lab/k8s/a1-block-jgroups-transport.yaml` 는 저장소 상대경로다. 체크아웃을 `kc-lab-1` 의 어디에 뒀는지는 원 가이드에 없고 거기로 옮기는 명령도 없으므로(unknown), 이 상대경로가 그대로 통하는 디렉터리에서 시작한다. 다른 디렉터리에서 치면 `cat``kubectl apply` 도 파일을 못 찾고 끝난다.
터미널은 둘을 연다. 하나는 임시 curl 파드용, 하나는 관찰용이다. 그래서 `[kc-lab-1]` 라벨이 붙은 블록이 `[탐침 파드]` 블록 사이에 끼어 있으면 **관찰용 터미널에서 친다** — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 `exit` 를 블록으로 따로 적는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 막는 포트 | `7800`(트랜스포트). `57800`(FD_SOCK2 = `bind_port + 50000`)도 함께 막힌다 |
| 주입 수단 | NetworkPolicy `a1-block-jgroups-transport` — 허용 목록이라 8080·9000 만 연다 |
| 탐침 파드 | `kc-probe``curlimages/curl:8.11.1`, `--rm -it`, `--restart=Never` |
| 분단 판정 | `jgroups_ping``coord = t` 가 두 줄 |
| 걸리는 시간 | 전 구간 약 30분 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
## 이 실험이 가르는 것
A-0 은 세션이 Infinispan 복제가 아니라 PostgreSQL 로 공유된다고 측정했다. Keycloak 24 이전 자료는 세션이 7800 으로 복제된다고 말한다. 통념은 7800 을 막으면 세션 공유가 깨진다고 예측하고 A-0 모델은 안 깨진다고 예측하므로, 7800 만 끊어 보면 둘 중 어느 쪽이 틀렸는지 판정된다.
끊을 때 두 가지를 갈라야 한다.
```text
디스커버리 노드가 서로를 어떻게 찾는가 → PostgreSQL 의 JGROUPS_PING 테이블
트랜스포트 실제로 어떻게 말하는가 → TCP 7800
```
트랜스포트만 막으면 DB 에는 둘 다 등록되어 있는데 메시지는 안 가는 상태가 된다. 단일 노드에서는 만들 수 없는 고장이고, 이 실험대가 VM 두 대인 까닭이 여기 있다.
절차를 끝까지 밟으면 NetworkPolicy 를 걸었는데도 클러스터가 안 깨지는 상태, `coord = t` 가 두 줄인 split brain, 분단인데도 교차 노드 refresh 가 `200` 인 것, 로그아웃했는데 반대편이 `200` 을 주는 것, 분단된 노드가 스스로 Service 에서 빠지는 것, 90초 만에 자동으로 다시 붙는 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- `05-keycloak` · `06-observability` 가 끝나 있다.
- 두 Keycloak 파드가 서로 다른 노드에 있어야 한다. 단일 노드에서는 이 고장을 만들 수 없다.
- `kc-lab-2``ssh` 로 붙을 수 있어야 한다. **conntrack 은 두 노드 모두에서** 봐야 한다.
**이건 상태를 부수는 실험이다.** Keycloak 클러스터를 실제로 분단시키고 파드를 재시작한다. **실험대에서만 한다.** 중간에 그만두려면 아래 한 줄이면 된다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
```
## 주입 전에 같은 명령으로 먼저 본다
차단 후에 볼 것을 차단 전에 똑같은 명령으로 먼저 봐 둔다. 시험군만 재는 측정은 측정이 아니다.
```text
노드 → 파드 → 정책 → 클러스터 뷰(로그) → 디스커버리(DB) → 지표(Prometheus) → 대조군 시험
```
### 1. 두 파드가 서로 다른 노드에 있는가
**무엇을 보는가** — 파드 둘의 상태와 배치, 그리고 뒤에서 쓸 파드 주소.
```bash label="[kc-lab-1] ① 노드를 본다"
kubectl get nodes
```
```bash label="[kc-lab-1] ② 파드가 어느 노드에 있는지 본다"
kubectl -n keycloak-lab get pods -o wide
```
```bash label="[kc-lab-1] ③ 파드 주소 두 개를 변수에 담는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**어디를 보나** — `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`, `NODE` 열이 서로 다른지. 실측은 `10.42.1.43 10.42.0.35` 였다(observed).
**이 값이 뜻하는 것** — `RESTARTS` 는 뒤에서 다시 센다. 이 값이 오르면 주입이 엉뚱한 곳을 건드렸다.
### 2. 기존 정책이 없는가
**무엇을 보는가** — 네임스페이스에 이미 걸린 NetworkPolicy.
```bash label="[kc-lab-1] 네임스페이스의 정책 목록"
kubectl -n keycloak-lab get networkpolicy
```
**어디를 보나** — 실측은 이렇다(observed, `01-baseline-cluster.txt`).
```text
No resources found in keycloak-lab namespace.
```
**이 값이 뜻하는 것** — NetworkPolicy 는 합집합으로 허용되므로 두 개가 겹치면 무엇이 열려 있는지 한눈에 안 보인다.
### 3. 양쪽 클러스터 뷰가 같은 줄인가
**무엇을 보는가** — 두 노드가 같은 멤버 목록을 찍고 있는지.
```bash label="[kc-lab-1] 양쪽 로그에서 마지막 클러스터 뷰 한 줄씩"
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
```
**어디를 보나** — 두 줄이 완전히 같은지. 실측은 이렇다(observed).
```text
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-0-30843` 의 뒤 숫자는 JGroups 가 붙인 것이고 파드가 재시작되면 바뀐다. 나중에 `keycloak-0-26403` 이 나오면 같은 파드의 새 인스턴스다.
### 4. 디스커버리 테이블에 둘 다 등록돼 있는가
**무엇을 보는가** — `JGROUPS_PING` 의 세 열.
```bash label="[kc-lab-1] 디스커버리 테이블 세 열"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
**어디를 보나** — `coord` 열의 `t` 개수. 실측은 이렇다(observed).
```text
name | ip | coord
------------------+-----------------+-------
keycloak-0-30843 | 10.42.1.43:7800 | f
keycloak-1-48749 | 10.42.0.35:7800 | t
(2 rows)
```
**이 값이 뜻하는 것** — 여기서 셋이 서로 다른 것을 말한다. 로그는 그때 그렇게 보였다는 기록이고, 테이블은 지금 등록되어 있다는 것이며, 지표는 지금 그 노드가 그렇게 안다는 것이다. A-1 에서 이 셋이 갈린다.
### 5. 두 노드가 아는 멤버 수를 지표로 본다
**무엇을 보는가** — 각 노드가 스스로 세는 클러스터 크기.
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
라벨을 보고 나면 읽기 좋게 자른다. 아래 두 줄은 가이드가 미검증으로 표시했고(unknown), 둘째 줄은 `jq` 가 깔려 있는 환경용이라 이 실험대에서는 쓸 수 없다.
```bash label="[kc-lab-1] ② 라벨을 보고 나서 필요한 줄만 자른다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
```bash label="[kc-lab-1] ③ jq 가 있는 환경이라면 이 형태 — 이 실험대에는 jq 가 없다"
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 label="[kc-lab-1] 병합 이벤트 계수기"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
```
실측은 이렇다(observed, `02-control-before-block.txt`).
```text
vendor_jgroups_merge3_get_num_merge_events 0.0 (양쪽 노드)
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 (양쪽 노드)
```
### 6. 대조군 시험을 차단 전에 한 번 돌린다
**무엇을 보는가** — 정상 클러스터에서 교차 노드 refresh 와 로그아웃 전파가 어떤 코드를 주는지.
임시 파드를 띄우고 그 안에서 A-0 과 같은 순서로 로그인·refresh·로그아웃을 친다. 임시 파드인 까닭은 셋이다. Keycloak 이미지에 `curl` 이 없어 Keycloak 파드 안에서는 못 치고, 토큰을 단계 사이로 넘겨야 하니 한 셸 안에서 다 끝내야 하며, Service 로 보내면 어느 노드가 처리했는지 알 수 없다. 이 실험의 질문 자체가 「어느 노드인가」라서 파드 주소로 직접 친다.
```bash label="[kc-lab-1] 임시 curl 파드를 띄우고 그 안의 셸로 들어간다"
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=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 파드 안에서는 `echo ${#PW}` 로 길이만 본다.
파드 안 셸에서 먼저 `keycloak-0` 에 로그인한다. 뒤의 refresh 가 쓰는 `$TOK` 와 `$RT` 가 여기서 생기므로 이 블록을 건너뛰면 다음 명령이 빈 문자열을 보낸다.
```sh label="[탐침 파드] ① keycloak-0 에 로그인해 refresh token 을 잡는다"
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')
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 access=${#AT}자"
```
`refresh=1187자 access=2043자` 같은 모양이 나온다. 길이가 `0자` 면 로그인이 실패한 것이고 `echo "$R"` 로 에러 본문을 본다.
같은 응답에 access token 도 들어 있지만 탐침으로 쓰지 않는다. access token 은 60초짜리고 그동안은 서버에 안 물어보므로, 노드가 세션 저장소를 실제로 뒤져야 답할 수 있는 refresh 를 쓴다.
```sh label="[탐침 파드] ② 반대편 노드에서 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')
```
마지막 줄이 `RT` 를 다시 담는 까닭은 refresh token 이 회전하기 때문이다. 갱신할 때마다 새 것이 나오므로, 옛 것을 계속 쓰면 뒤에 나오는 `400` 이 무효화 때문인지 재사용 때문인지 갈리지 않는다.
실측은 이렇다(observed, `02-control-before-block.txt`).
```text
sid tAWs2gCPr6SOcD4jDR9-_CzB
keycloak-1 에서 refresh: 200
```
**이 값이 뜻하는 것** — 이 `200` 이 대조군이다. 차단 후에도 200 이면 원래 되던 것이 그대로 된 것이고, 차단 후 400 이면 내가 깨뜨렸다는 뜻이다.
로그아웃 전파도 대조군을 잡는다.
```sh label="[탐침 파드] ③ 반대편에서 로그아웃하고 발급 노드에서 다시 갱신해 본다"
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` 다음에 이 두 줄이 나온다.
```text
{"error":"invalid_grant","error_description":"Session not active"}
400
```
이 `400` 은 A-0 에서 측정한 값이고, A-1 의 대조군 기록에는 refresh `200` 만 있고 로그아웃 단계가 없다. 그래서 차단 전에 직접 재 두는 편이 낫다.
대조군을 다 잡았으면 파드 셸에서 나온다. 나가지 않으면 `kc-probe` 가 그대로 살아 있어서, 뒤에서 같은 이름으로 다시 띄울 때 `AlreadyExists` 로 거절된다.
```sh label="[탐침 파드] ④ 나온다. --rm 이 파드를 지운다"
exit
```
## 주입
### 1. NetworkPolicy 로 7800 만 뺀다
**목적** — 8080 과 9000 은 열어 둔 채 7800 으로 오는 새 연결만 막는다.
```bash label="[kc-lab-1] ① 매니페스트를 먼저 읽는다"
cat deploy/lab/k8s/a1-block-jgroups-transport.yaml
```
파일은 앞 31행이 영어 주석이고 그 아래가 매니페스트다. 주석 31행을 뺀 본문 전문은 이렇다. 매니페스트 안에 남은 `#` 주석도 파일에 적힌 영어 그대로다.
```yaml label="a1-block-jgroups-transport.yaml — 주석을 뺀 본문 전문"
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: a1-block-jgroups-transport
namespace: keycloak-lab
spec:
podSelector:
matchLabels:
app: keycloak
policyTypes: [Ingress]
ingress:
- ports:
- { port: 8080, protocol: TCP } # HTTP — must stay open
- { port: 9000, protocol: TCP } # health + metrics — must stay open
# 7800 is absent on purpose. That is the whole experiment.
```
① 에서 이 매니페스트를 못 찾으면 파일을 직접 만든다. 같은 경로를 에디터로 열어 위 열다섯 줄을 그대로 넣고 저장한다. `printf` 나 `cat <<EOF` 로 찍어 내지 않는다. 주입 검증에서 `describe` 로 대조할 것이 이 줄들이고, 덧붙이는 형태로 쓰면 같은 절차를 두 번 밟았을 때 파일에 같은 내용이 두 벌 쌓인다. 파일을 여는 명령은 원 가이드에 없다(unknown).
매니페스트 안에 `metadata.namespace` 가 `keycloak-lab` 으로 적혀 있다. ② 의 `apply` 에는 `-n` 이 없으니 네임스페이스는 이 줄이 정한다. 손으로 옮겨 적다 이 줄을 빠뜨리면 정책이 `default` 네임스페이스에 걸린다. 거기에는 `app=keycloak` 인 파드가 없으니 정책은 만들어지는데 아무 파드도 안 잡힌다.
```bash label="[kc-lab-1] ② 적용하고 시각을 남긴다"
kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
date '+%H:%M:%S 적용'
```
**예상 결과** — 실측은 이렇다(observed, `03-block-applied.txt`).
```text
networkpolicy.networking.k8s.io/a1-block-jgroups-transport created
적용 시각: 11:38:08
```
**왜 필요한가** — NetworkPolicy 는 방화벽이 아니라 허용 목록이다. 「7800 을 거부」라고 쓸 방법이 없고, 파드가 `policyTypes: [Ingress]` 를 가진 정책에 선택되는 순간 모든 인바운드가 거부되며 규칙에 적힌 것만 통과한다. 7800 은 빠뜨림으로써 막힌다. 그 구조 때문에 두 허용 규칙이 결정적이다 — 8080 을 빼면 Traefik 과 상대 노드의 REST 호출이 전부 끊기고, 9000 을 빼면 readiness 프로브가 실패해 kubelet 이 파드를 죽여 엉뚱한 이유로 클러스터가 깨진다. 덤으로 57800 도 막힌다. FD_SOCK2(장애 감지 채널)는 `bind_port + 50000` 을 쓰는데, 손으로 「7800 거부」 규칙을 쓰면 이 포트를 빠뜨리기 쉽고 허용 목록 방식은 8080·9000 외 전부 거부이므로 자동으로 같이 막힌다.
**문제가 생기면** — 적용 시각을 반드시 적어 둔다. 이 실험은 시각이 겹친 것을 인과로 잘못 읽었다가 나중에 정정했다.
## 주입 검증
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 본다. 「아무 일도 없었다」는 「영향이 없다」와 구별되지 않아서, 걸렸는지를 결과와 따로 확인하지 않으면 둘이 같은 화면으로 보인다. 원 가이드는 이 순서를 이 실험이 남긴 가장 큰 교훈으로 적고, 뒤의 실험 전부에 같은 확인을 요구한다. 이 편에서는 그 확인이 「안 걸렸다」를 먼저 알려 준다.
### 1. 정책이 어떤 파드를 잡았는가
```bash label="[kc-lab-1] 정책 목록과 상세"
kubectl -n keycloak-lab get networkpolicy
kubectl -n keycloak-lab describe networkpolicy a1-block-jgroups-transport
```
`To Port` 목록에 7800 이 없는 것, 그리고 `PodSelector` 가 `app=keycloak` 인 것을 본다. 오타로 아무 파드도 안 잡히면 정책은 걸렸는데 아무 일도 일어나지 않는다.
```bash label="[kc-lab-1] 파드가 죽지 않았는지 본다"
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
실측은 `keycloak-0 ready=true restarts=0`, `keycloak-1 ready=true restarts=0` 이다(observed). `RESTARTS` 가 여전히 0 이면 9000 을 제대로 열어 뒀다는 뜻이다.
### 2. 열어 둔 포트는 살아 있고 막은 포트는 죽었는가
```bash label="[kc-lab-1] 임시 파드를 띄운다"
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 label="[탐침 파드] 포트 셋에 차례로 붙어 본다"
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=$?"
```
실측은 이렇다(observed).
```text
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 (연결 실패)
```
curl 종료코드로 읽는다 — `7` 은 연결 자체가 안 됨, `28` 은 `--max-time` 초과로 SYN 이 조용히 버려지고 있음, `0` 은 닿았다는 뜻이라 정책이 안 걸렸다.
포트 셋을 다 봤으면 또 나온다. 이 파드도 `kc-probe` 라 살려 두면 본 시험에서 같은 이름이 겹친다.
```sh label="[탐침 파드] 나온다"
exit
```
### 3. 그런데 클러스터가 안 깨졌다
```bash label="[kc-lab-1] 두 노드가 아는 멤버 수를 다시 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
실측은 이렇다(observed, `07-cluster-size.txt`).
```text
=== 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
```
여기서 실험 실패라고 결론 내리면 틀린다. 파드 안 소켓을 본다. Keycloak 이미지에는 `ss` 도 없으므로 `/proc` 을 직접 읽는다.
```bash label="[kc-lab-1] 7800 은 16진수로 1E78 이다"
kubectl -n keycloak-lab exec keycloak-0 -- cat /proc/net/tcp6 | grep 1E78
```
실측은 이렇다(observed).
```text
=== /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 다. 기존 연결이 멀쩡히 살아 있다.
까닭은 conntrack 이다.
```text
패킷 도착
├─▶ [ conntrack: ESTABLISHED/RELATED 이면 ACCEPT ] ← 여기서 통과해버린다
└─▶ [ NetworkPolicy 규칙 평가 ] ← 여기까지 오지 않는다
```
### 4. conntrack 을 두 노드 모두에서 본다
```bash label="[kc-lab-1] 이 노드의 conntrack 표를 본다"
sudo conntrack -L 2>/dev/null | grep 7800
```
반대편 노드는 붙어서 친다. 아래 세 줄 형태는 이 실험대에서 치지 않았다(unknown) — 원래 실행은 `ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'` 한 줄로 쳤고, 한 줄에 원격 접속과 원격 셸의 인용을 겹쳐 놓는 대신 행동 하나를 명령 하나로 나눴다.
```bash label="[kc-lab-1] ① 반대편 노드에 붙는다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] ② 같은 표를 본다"
sudo conntrack -L 2>/dev/null | grep 7800
```
```bash label="[kc-lab-2] ③ 나온다"
exit
```
폴더 README 는 게스트 셸이 필요한 것을 `nft`·`tc`·`systemctl` 처럼 노드 자체를 건드리는 명령뿐이라고 적는데, `conntrack` 이 그 경우다.
실측은 이렇다(observed, `05-conntrack-problem.txt`).
```text
--- 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` 도 ESTABLISHED 로 살아 있다. NetworkPolicy 는 이미 붙어 있는 연결을 떼어내지 못하므로, 보안 사고 대응으로 「지금 당장 이 통신을 끊어라」에 NetworkPolicy 를 걸면 새 연결만 막히고 진행 중인 연결은 계속된다. 같은 7800 을 한 방향만 끊는 A-5 가 NetworkPolicy 대신 `iptables` 의 raw PREROUTING 으로 간 까닭도 여기서 나왔다 — 그 체인은 conntrack 조회보다 먼저 평가된다.
### 5. conntrack 항목을 튜플 그대로 지운다
**목적** — 규칙 평가를 건너뛰게 만들던 기존 연결 기록을 표에서 없앤다.
네 값은 바로 위 4단계의 `conntrack -L` 출력에서 읽는다. 한 줄에 `src=` `dst=` `sport=` `dport=` 가 두 벌 나오는데 **앞의 한 벌이 원 방향, 뒤의 한 벌이 응답 방향**이고 두 벌을 각각 한 번씩 지운다. 포트는 ephemeral 이라 재시작할 때마다 바뀐다.
```bash label="[kc-lab-1] -L 출력의 네 값을 그대로 옮겨 한 항목씩 지운다"
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>
```
이 실험대에서는 7800 두 방향과 57800 한 방향, 이렇게 세 번 쳤다(observed). 그대로 옮겨 치면 자기 실험대에는 없는 튜플이라 0건이 나온다.
```text
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` 에서도 같은 일을 한다. 서버 쪽 노드에는 튜플이 뒤집혀 기록되어 있으므로, 그쪽에 붙어 그쪽 `-L` 출력을 보고 옮긴다.
```bash label="[kc-lab-1] ① 반대편 노드에 붙는다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] ② 이 노드의 표를 다시 보고 네 값을 읽는다"
sudo conntrack -L 2>/dev/null | grep 7800
```
```bash label="[kc-lab-2] ③ 읽은 값으로 지운다"
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>
```
```bash label="[kc-lab-2] ④ 나온다"
exit
```
**예상 결과** — 삭제 건수가 나온다.
**왜 필요한가** — 위 소켓 출력이 보여 준 대로 ESTABLISHED 인 연결은 규칙 평가 앞에서 통과한다. 그 기록을 지워야 다음 패킷이 정책을 만난다.
**문제가 생기면** — `0 flow entries have been deleted` 면 튜플이 틀린 것이고, `--dport 7800` 만 주면 0건이 나온다. 원래 실행에서 실제로 그렇게 나왔다.
여기에 정직하게 적어 둘 것이 있다(observed). 원래 실행에서 conntrack 을 지운 뒤에도 `vendor_cluster_size` 는 계속 2 였다. 해설 문서는 처음에 「conntrack 삭제 → 3분 뒤 분단」이라고 썼다가 증거를 다시 보고 정정했고, 실제 하락은 파드가 재시작된 4초 뒤에 일어났다. 이 단계만으로 분단이 만들어지는지는 이 실험이 판정하지 못했다.
### 6. 정책이 걸린 채로 파드를 지워 분단을 확정한다
**목적** — 새로 뜨는 노드가 7800 으로 JOIN 을 보내다 실패하게 만들어 분단을 확실히 만든다.
```bash label="[kc-lab-1] ① 시각을 남기고 파드를 지운다"
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab delete pod keycloak-0
```
```bash label="[kc-lab-1] ② 새 파드와 새 주소를 다시 잡는다"
kubectl -n keycloak-lab get pods -o wide | grep keycloak
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
echo "$K0"
```
**예상 결과** — 실측은 이렇다(observed, `08-restart-forced-partition.txt`).
```text
재시작 시각: 11:46:07
pod "keycloak-0" deleted from keycloak-lab namespace
keycloak-0 false 10.42.1.67 2026-09-04T02:44:23Z
```
**왜 필요한가** — StatefulSet 이 같은 이름으로 곧바로 다시 만들지만 주소는 바뀐다. `10.42.1.43` 에서 `10.42.1.67` 로 바뀌었고, 새 주소를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿는다.
`echo "$K0"` 가 빈 줄이면 파드에 아직 주소가 붙지 않은 것이다. 이 파드는 분단 때문에 Ready 가 되지 않으므로 `wait --for=condition=Ready` 로 기다리면 안 되고, 첫 줄의 `get pods -o wide` 에 `IP` 가 찍힐 때까지 ② 를 다시 친다. 빈 값을 그대로 두고 넘어가면 `http://:8080` 으로 요청이 나가고 그 실패를 분단으로 읽는다.
**문제가 생기면** — 파드가 `Pending` 에서 안 넘어가면 노드 상태부터 본다.
## 관찰
```bash label="[kc-lab-1] 두 노드가 아는 멤버 수"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
실측은 이렇다(observed).
```text
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 이다. 서로를 멤버로 안 세고 있고, 로그가 까닭을 말한다.
```bash label="[kc-lab-1] 합류 시도와 클러스터 뷰를 한 번에 본다"
kubectl -n keycloak-lab logs keycloak-0 | grep -E "GMS|ISPN000094" | tail -20
```
실측은 이렇다(observed).
```text
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]
```
새로 뜬 `keycloak-0` 은 DB 에서 `keycloak-1` 을 찾았고 주소도 안다. 그런데 JOIN 메시지가 7800 으로 안 간다. 디스커버리는 살아 있고 트랜스포트만 죽었다.
split brain 은 DB 한 줄로 확인된다.
```bash label="[kc-lab-1] 코디네이터가 몇인지 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
실측은 이렇다(observed, `06-partition-observed.txt`).
```text
name | ip | coord
------------------+-----------------+-------
keycloak-0-26403 | 10.42.1.67:7800 | t ← 코디네이터
keycloak-1-48749 | 10.42.0.35:7800 | t ← 코디네이터
```
`coord = t` 가 둘이다. 서로를 못 보니까 각자 자기가 대장이라고 생각한다. 분단을 확인하는 가장 짧은 명령이 이 한 줄이다.
분단된 노드는 스스로 트래픽에서 빠진다.
```bash label="[kc-lab-1] Ready 와 재시작 횟수만 뽑아 본다"
kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
| grep keycloak
```
실측은 `keycloak-0 false 0`, `keycloak-1 true 0` 이다(observed, `11-service-impact.txt`). 까닭은 헬스 본문에 있다.
```bash label="[kc-lab-1] 파드 조건을 본다"
kubectl -n keycloak-lab describe pod keycloak-0 | grep -A5 Conditions
```
실측은 이렇다(observed).
```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 이고, 쿠버네티스가 그 신고를 받아 처리한다.
```bash label="[kc-lab-1] Service 뒤에 누가 남았는지 본다"
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]`, `notReady : [10.42.1.67]` 다(observed). `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다.
```bash label="[kc-lab-1] 밖에서 정문을 친다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
실측은 정문이 `HTTP 200`, 토큰 발급도 `HTTP 200` 이다(observed). 분단된 노드가 스스로 로드밸런서에서 빠졌고 서비스는 계속됐다. liveness 였다면 재시작을 반복했을 텐데 재시작해도 안 나아지는 문제이므로 readiness 로 격리하는 쪽이 맞는 신호다. 다만 비대칭이라서 살았다 — `keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고, `keycloak-0` 은 합류 자체를 못 해 DOWN 이 됐다. 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애이고, 그것이 A-5 의 주제다.
본 시험은 Service 를 쓰면 안 된다. `keycloak-0` 이 NotReady 라 Service 로 보내면 전부 `keycloak-1` 로 간다. 새 주소로 임시 파드를 다시 띄우고 파드 주소로 직접 친다.
`keycloak-0` 을 지웠으므로 `$K0` 가 낡았다. 두 주소를 다시 잡고 그 값으로 파드를 띄운다. `$PW` 도 여기서 다시 넣는다 — 앞의 포트 시험 파드에는 안 넣었다.
```bash label="[kc-lab-1] ① 새 주소를 잡고 탐침 파드를 다시 띄운다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
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=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
```sh label="[탐침 파드] ② 분단 상태에서 네 단계를 차례로 친다"
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"
```
네 단계를 다 쳤으면 파드 셸에서 나온다. 뒤의 명령은 전부 `[kc-lab-1]` 이고, 나가지 않으면 `kubectl` 도 `psql` 도 없는 `curlimages/curl` 안에서 치게 된다.
```sh label="[탐침 파드] ③ 나온다. --rm 이 파드를 지운다"
exit
```
실측은 이렇다(observed, `09-cross-node-under-partition.txt`).
```text
[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] 에서 로그아웃 전파는 예측이 틀렸다. 대조군에서 400 이던 곳이 200 이다.
[4] 의 200 이 로그아웃이 아예 안 됐다는 뜻인지 확인한다. sid 는 [1] 에서 JWT payload 를 풀어 화면에 찍고 적어 둔 그 값이다. 로그인할 때마다 새로 생기므로 자기 실행의 값을 넣는다.
```bash label="[kc-lab-1] 그 세션의 DB 행이 남아 있는지 본다"
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='{{SID}}'"
```
이 실험대의 값은 `nShl5TaBrZnKStDqaspjgmJB` 였다(observed).
실측은 이렇다(observed, `10-logout-not-propagated.txt`).
```text
user_session_id | offline_flag | last_session_refresh
-----------------+--------------+----------------------
(0 rows) ← DB 행은 삭제되었다
```
```bash label="[kc-lab-1] 세션 캐시 엔트리를 노드별로 본다"
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` 이다(observed). 캐시에는 그 세션이 있다.
```text
keycloak-1 로그아웃
├──▶ PostgreSQL 행 삭제 ✔ 되었다
└──▶ keycloak-0 에게 "캐시에서 지워라" ✗ 7800 이 막혀 못 갔다
keycloak-0 은 자기 캐시로 200 을 준다 ◀────────────┘
```
룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다. 세션 조회는 PostgreSQL 을 타고 세션 무효화는 클러스터 메시지(7800)를 타므로, 7800 을 막으면 조회는 정상이고 무효화만 전파되지 않는다. 실제 사용자도 로그아웃이 안 되는지는 따로 답이 있다 — 아니다. 파드 주소로 직접 쳤기 때문이고, 실제 사용자는 nginx → Traefik → Service 를 거치는데 NotReady 인 `keycloak-0` 은 거기서 빠져 있다.
## 복구와 원상복구 확인표
### 1. 정책을 지우고 재형성을 기다린다
**목적** — 7800 을 다시 열어 두 노드가 하나의 뷰로 합쳐지게 한다.
```bash label="[kc-lab-1] ① 시각을 남기고 정책을 지운다"
date '+%H:%M:%S 해제'
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
```
```bash label="[kc-lab-1] ② 30초 간격으로 멤버 수를 몇 번 친다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**예상 결과** — 실측은 이렇다(observed, `12-recovery.txt`).
```text
해제 시각: 11:49:58
networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace
```
```text
+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 이면 MERGE3 주기 밖이거나 정책이 안 지워진 것이므로 `get networkpolicy` 부터 본다.
### 2. 누가 붙였는지 카운터로 확인한다
**목적** — 재형성이 저절로 일어난 것인지 확인한다.
```bash label="[kc-lab-1] 병합 이벤트 계수기를 다시 읽는다"
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` 이다(observed). 주입 전에 `0.0` 이던 값이 1 이다.
**왜 필요한가** — MERGE3 는 split brain 을 감지해 갈라진 뷰를 병합하는 JGroups 프로토콜이고, `0 → 1` 로 오른 카운터가 그 프로토콜이 실제로 일했다고 말한다.
**문제가 생기면** — 값이 그대로 0 이면 재형성이 다른 경로로 일어났거나 아직 안 일어난 것이므로 `vendor_cluster_size` 를 다시 본다.
코디네이터도 하나로 돌아온다. 실측은 이렇다(observed).
```text
keycloak-0-26403 | 10.42.1.67:7800 | t
keycloak-1-48749 | 10.42.0.35:7800 | f ← 코디네이터가 하나로 돌아왔다
```
코디네이터가 `keycloak-1` 에서 `keycloak-0` 으로 넘어갔다. 코디네이터는 특권이 아니라 역할이며 병합 시 재선출되므로 주입 전과 달라도 정상이다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 정책 | `kubectl -n keycloak-lab get networkpolicy` | `No resources found` |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| Service | `kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 클러스터 뷰 | `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` 가 **하나** |
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
| 임시 파드 | `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` |
conntrack 은 지운 채로 두면 된다. 표는 새 패킷이 오면 다시 채워진다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| 정책을 걸었는데 지표가 안 변한다 | conntrack 의 ESTABLISHED 가 먼저 통과시킨다 | `sudo conntrack -L 2>/dev/null \| grep 7800` |
| `conntrack -D` 가 `0 flow entries` | 튜플이 틀렸다. `--dport` 만으로는 0건 | `-L` 출력의 src/dst/sport/dport 를 **그대로** 옮긴다 |
| conntrack 을 지웠는데도 계속 2 | **이 실험은 그것만으로 분단되는지 판정 못 했다** | 정책이 걸린 채 파드를 재시작한다 |
| 파드가 재시작을 반복한다 (`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 로 직접** |
| 재시작 뒤 아무 데도 안 닿는다 | **파드 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` 가 빈 값 |
| 값이 빈 문자열인데 「변했다」로 읽힌다 | **원래 실행이 이 실수를 했다** | 빈 값은 「측정 실패」다. 판정 조건에서 빼고 다시 잰다 |
| 복구 후 2~3분이 지나도 1 | MERGE3 주기 밖이거나 정책이 안 지워졌다 | `get networkpolicy` 로 먼저 확인 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 11:3811:52 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 차단 `11:38:08`·재시작 `11:46:07`·해제 `11:49:58`, 25분 내내 2 이던 `vendor_cluster_size`, `/proc/net/tcp6` 의 `01`, conntrack 네 줄, `coord = t` 가 둘, 분단 중 교차 refresh `200` 과 로그아웃 후 `200`, DB 행 0 과 캐시 1, 90초 재형성, `merge_events` 가 `0 → 1`.
- (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력과 `jq` 형태. `jq` 는 이 실험대에 아예 없다.
- (unknown) `ssh kc-lab-2` 로 들어가서 `conntrack -L` 을 따로 치는 세 줄 형태. 이 실험대는 `ssh kc-lab-2 '...'` 한 줄로 쳤다.
- (unknown) 매니페스트가 놓인 체크아웃의 위치와 그 디렉터리로 옮기는 명령, 그리고 파일이 없을 때 여는 에디터 명령. 원 가이드는 `cat` 과 `kubectl apply` 를 저장소 상대경로로만 적는다.
- 이 실험이 판정하지 못한 것 — conntrack 삭제만으로 분단이 만들어지는지. 해설 문서가 「3분 뒤 분단」이라고 썼다가 정정했고, 실제 하락은 파드 재시작 4초 뒤였다.
- 이 실험이 재지 않은 것 — `keycloak-0` 캐시에 있던 낡은 엔트리가 병합 후 어떻게 되는지. 궁금하면 재형성 뒤에 `vendor_statistics_approximate_entries_unique{cache="sessions"}` 를 다시 본다.
- 가이드가 스크립트를 안 쓰는 까닭도 측정 실패에서 나왔다(observed). 원래 실행은 임시 파드를 20초마다 띄워 지표를 긁었고 `+20초 suspected(k0 k1) = []` 처럼 빈 값과 개수가 안 맞는 값이 섞였다. 판정 조건이 `[ "$R" != "0.0 0.0 " ]` 이어서 빈 문자열을 변화로 읽고 즉시 빠져나왔다.
<!-- body:end -->
@@ -0,0 +1,956 @@
---
id: b8d7db33-afdc-4e49-9eab-ed4edbbe398e
kind: SETUP
slug: reproduce-a7-volatile-comparison
title: persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b8d7db33-afdc-4e49-9eab-ed4edbbe398e/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
- name: persistent-user-sessions
version: v1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다
`persistent-user-sessions` 를 끈 옛 기본값 위에서 A층 실험 넷을 다시 치는 절차다. A-0 과 A-1 과 A-2 와 A-8 을 명령 한 글자도 바꾸지 않고 그대로 친다. 교차 노드 refresh 는 `200` 인데 DB 세션 행은 `(0 rows)` 가 된다. 전 구간 40~60분.
## 관계
- **같은 설정이 캐시 온도만으로 세 가지 답을 냈다**
이 절차의 마지막 측정값 `① 500 · ② 200` 이 조건부라는 것을 그 기록이 확정한다.
- **persistent-user-sessions 가 세션의 거처를 정한다**
여기서 끄고 켜는 그 기능이 무엇을 바꾸는지는 그 기록이 설명한다.
- **버전과 설정을 결과와 함께 적는다**
같은 명령이 26.7.0 기본값과 옛 기본값에서 정반대 답을 내므로, 결과만 옮겨 적으면 틀린 말이 된다.
- **문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다**
이 절차가 남긴 `500` 의 원인을 확정하는 후속 절차이고, 주입도 복구도 따로 선다.
- **롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다**
기본값에서 그 시험이 `200` 인 것을 먼저 재 둬야 여기의 `400` 이 뒤집힘으로 읽힌다.
- **7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다**
기본값에서 세션 공유가 안 깨지던 그 주입을 여기서 다시 건다. 이번에는 깨진다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령을 치는 곳이 둘이다. 대부분은 `[kc-lab-1]` 에서 `kubectl` 로 치고, `iptables` 만 노드 자체를 건드리므로 `kc-lab-1``kc-lab-2` 에 각각 들어가 친다. 코드블록마다 `label` 로 어디서 치는지 붙였다.
`kubectl``sudo` 를 붙이지 않는다. 실험 폴더의 README 가 까닭을 적는다 — `sudo` 를 붙이면 root 환경으로 돌아 그 kubeconfig 를 못 본다. root 홈에는 `~/.kube/config` 가 없으므로 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝나고, 그러면 클러스터가 아니라 누구의 설정 파일을 읽느냐가 문제인데 클러스터를 의심하게 된다.
```bash label="[kc-lab-1] 두 형태의 차이"
kubectl -n keycloak-lab get pods # 이렇게
sudo kubectl -n keycloak-lab get pods # 이렇게 치면 안 된다
```
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
| 탐침 파드 | `a7-probe` — `curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 끄는 기능 | `--features-disabled=persistent-user-sessions` |
| 막는 포트 | 7800 과 57800 을 `raw PREROUTING` 에서 양방향으로 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 |
터미널은 둘을 연다. 하나는 관찰용, 하나는 대기용이다.
## 이 실험이 가르는 것
A층의 결론 여섯은 전부 하나의 전제 위에 있다.
```text
Keycloak 26 은 persistent-user-sessions 가 기본으로 켜져 있다
├─ A-0 세션은 PostgreSQL 에 있다
├─ A-1 7800 을 끊어도 세션 공유가 안 깨진다
├─ A-2 DB 를 내리면 로그인이 실패한다
└─ A-8 롤링 재시작을 해도 세션이 산다
```
A-1 은 인터넷 자료의 통념과 어긋난 답을 냈고 그 까닭을 「26 이 기본값을 바꿨기 때문」이라고 설명했다. 설명이 맞는지는 옛 기본값으로 되돌려 같은 실험을 다시 해야 판정된다. 자료가 틀린 것이 아니라 버전이 다른 것이라면 옛 설정에서는 통념이 맞아야 한다.
```text
persistent (KC 25+, 26 기본) volatile (KC 24 이전)
로그인 ─▶ PostgreSQL (진실) 로그인 ─▶ Infinispan (진실)
조회 ─▶ 캐시 없으면 DB 조회 ─▶ 클러스터에서 찾는다
공유 ─▶ 같은 DB 를 본다 공유 ─▶ 7800 을 통한 복제
```
이 절차를 끝까지 치면 다섯을 손으로 보게 된다. 로그인했는데 DB 세션 테이블이 0건인 것, 그런데도 교차 노드 refresh 가 `200` 인 것, 롤링 재시작 한 번에 전원이 로그아웃되는 것, 7800 을 끊으면 이번에는 세션 공유가 깨지는 것, 그리고 DB 를 내렸는데 새 로그인이 되는 것.
## 전제와 되돌리기
앞선 구축 단계 `05-keycloak` 과 `06-observability` 가 끝나 있어야 한다. 그리고 A-1 과 A-2 와 A-8 을 먼저 해 두는 편이 좋다. 이 절차는 그 셋의 대조군이고, 먼저 잰 값을 알고 있어야 뒤집힘이 보인다.
이건 클러스터의 동작 모드를 바꾸는 실험이다. 전환하는 순간 기존 세션이 전부 사라지고 되돌릴 때 또 한 번 사라진다. `--features-disabled` 는 빌드 옵션이라 기동할 때 재빌드가 일어나 롤아웃이 평소보다 오래 걸린다. 실험대에서만 한다.
원복을 잊으면 이후 실험이 전부 오염된다. A-0 부터 A-6 까지의 결론은 전부 persistent 기본값 조건이다.
중간에 그만두려면 두 가지를 되돌린다. `args` 쪽은 이 두 줄이다.
```bash label="[kc-lab-1] args 를 기본값으로 되돌린다"
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
`iptables` 쪽은 두 노드에서 각각 지운다. 이 실험대는 `ssh kc-lab-2 '...'` 한 줄로 쳤고, 따라 하는 사람은 먼저 붙은 다음 원격 셸에서 치면 된다. 나눈 형태는 이 실험대에서 치지 않았다(unknown).
```bash label="[kc-lab-1] 이 노드의 규칙을 지운다"
sudo iptables -t raw -F PREROUTING
```
```bash label="[kc-lab-1] 반대 노드로 붙는다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] 원격 셸에서 같은 것을 지우고 나온다"
sudo iptables -t raw -F PREROUTING
exit
```
## 주입 전에 같은 명령으로 먼저 본다
전환 후에 볼 것을 전환 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
```text
노드 → 파드 → 지금 args → DB 세션 행 → 대조군 시험 → 이 버전에서 끌 수 있는가
```
### 1. 파드 배치를 보고 두 파드 IP 를 잡는다
**목적** — 두 Keycloak 파드가 서로 다른 노드에 있는지 확인하고, 뒤에서 쓸 IP 를 변수에 담는다.
```bash label="[kc-lab-1] ① 노드와 파드를 넓게 본다"
kubectl get nodes
kubectl -n keycloak-lab get pods -o wide
```
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
```text
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` 가 서로 다른지를 본다. 파드 번호와 노드 번호는 어긋난다 — `keycloak-0` 이 `kc-lab-2` 에 있다.
```bash label="[kc-lab-1] ② IP 를 변수에 담는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
```text
10.42.1.94 10.42.0.45
```
**왜 필요한가** — 두 파드가 같은 노드에 있으면 뒤의 노드 간 차단이 아무것도 끊지 않는다. 그리고 이 절차는 롤아웃을 세 번 하므로 IP 를 세 번 다시 잡는다.
**문제가 생기면** — `NODE` 가 같으면 여기서 멈추고 배치부터 고친다.
### 2. 지금 args 를 적어 둔다
**목적** — 복구할 때 되돌릴 문자열을 확보한다.
```bash label="[kc-lab-1] 컨테이너 args 를 그대로 찍는다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**예상 결과**
```text
["start"]
```
**왜 필요한가** — 플래그가 하나도 없으므로 26 의 기본값으로 돌고 있고 `persistent-user-sessions` 가 켜져 있다. 복구 단계가 이 문자열로 되돌린다.
**문제가 생기면** — 이미 `--features-disabled=persistent-user-sessions` 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 먼저 그것부터 되돌린다.
### 3. DB 에 세션 행이 있는 것을 센다
**목적** — persistent 에서 로그인이 DB 행을 만든다는 것을 전환 전에 확인한다.
```bash label="[kc-lab-1] 온라인 세션과 offline token 을 나눠 센다"
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"
```
**예상 결과** — 모양은 이렇고 숫자는 환경마다 다르다.
```text
offline_flag | count
--------------+-------
0 | 151
```
`offline_flag = '0'` 이 온라인 세션이고 `'1'` 은 offline token 이라 이 실험과 무관하다.
**왜 필요한가** — 전환 후에 같은 질의가 `(0 rows)` 를 내놓는지가 첫 판정이다. 관리 API 호출도 세션을 만들기 때문에 개수에는 소음이 섞인다. 여기서는 0 이 아니라는 것만 본다.
**문제가 생기면** — `(0 rows)` 가 지금 나오면 이미 volatile 이다. 2번으로 돌아간다.
### 4. 상주 탐침 파드를 띄운다
**목적** — 롤링 재시작을 넘어 토큰을 들고 있을 파드를 StatefulSet 밖에 세운다.
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어서 `kubectl exec keycloak-0 -- curl` 은 `exit 127` 로 끝난다.
```bash label="[kc-lab-1] ① 탐침을 띄우고 Ready 를 기다린다"
kubectl -n keycloak-lab run a7-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7-probe --timeout=120s
```
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 존재와 길이만 본다.
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
```text
19
```
```bash label="[kc-lab-1] ③ 탐침 안에 값이 들어갔는지 본다"
kubectl -n keycloak-lab exec a7-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
**예상 결과** — 두 IP 가 보이고 `PW길이` 가 0 이 아니다.
**왜 필요한가** — 탐침이 StatefulSet 안에 있으면 롤링 재시작에 같이 죽어서 재시작 전 토큰을 재시작 후에 쓸 수 없다.
**문제가 생기면** — `PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
### 5. 교차 노드 refresh 가 지금은 되는 것을 본다
**목적** — 뒤에서 나올 `400` 과 견줄 값을 먼저 확보한다.
응답을 한 번은 통째로 본다.
```bash label="[kc-lab-1] ① 로그인 응답 전문을 본다"
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 label="[kc-lab-1] ② 토큰을 파드 안 파일에 담고 길이를 찍는다"
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"'
```
```text
rt 1188 bytes
```
```bash label="[kc-lab-1] ③ 반대 노드에서 그 토큰으로 갱신한다"
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)"'
```
**예상 결과**
```text
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
```
**왜 필요한가** — 이 `200` 을 안 재 두면 뒤의 `400` 이 무엇과 견준 값인지 말할 수 없다. 그리고 `rt` 가 `1 bytes` 면 빈 문자열에 개행만 들어갔다. 파싱이 실패했거나 로그인이 실패한 것인데, 그 상태로 진행하면 빈 토큰을 보내고 그 응답을 「세션이 죽었다」로 읽게 된다.
**문제가 생기면** — `1 bytes` 가 나오면 `cat /tmp/tok` 으로 본문을 본다. refresh token 은 회전하므로 이어서 또 쓰려면 새로 로그인해서 `/tmp/rt` 를 다시 채운다.
### 6. 이 버전에서 정말 끌 수 있는지 확인한다
**목적** — 기능 목록에 이름이 있는지 본다.
```bash label="[kc-lab-1] 빌드 기능 목록에서 이름을 찾는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
| tr ',' '\n' | grep -i persistent
```
**예상 결과**
```text
persistent-user-sessions[:v1] ← 목록에 있다
```
`--help-all` 은 출력이 길고 기능 목록이 한 줄에 쉼표로 이어 붙어 나온다. `tr ',' '\n'` 이 그것을 줄로 쪼갠다. 처음 한 번은 `grep` 없이 쳐서 어떤 기능들이 있는지 통째로 본다.
**왜 필요한가** — 목록에 없으면 그 버전에서는 이 절차를 할 수 없다. 기능이 제거돼 기본 동작으로 고정된 것이고, 그 자체가 답이다.
**문제가 생기면** — 아무것도 안 나오면 먼저 `grep` 을 떼고 출력 전체를 본다.
## 주입
주입은 셋이다. 여기서 치는 것은 첫째뿐이고, 7800·57800 양방향 차단과 PostgreSQL 정지는 A-1 과 A-2 를 다시 치는 순서 안에서 넣는다. 그 둘의 명령과 되돌리기는 그 단계에 적었다.
### 7. 세션 테이블을 비운다
**목적** — 전환 후 「DB 0건」이 성립할 수 있게 옛 행을 먼저 없앤다.
```bash label="[kc-lab-1] 온라인·오프라인 세션 행을 전부 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
**예상 결과**
```text
DELETE 151
```
**왜 필요한가** — volatile 은 새로 쓰지 않을 뿐 옛 행을 지우지도 않는다. 이 한 줄을 빼먹으면 전환 뒤에도 테이블에 행이 보이고, 그것을 「전환이 안 됐다」로 읽게 된다. 되돌리는 방법은 없다 — 지운 세션은 돌아오지 않는다. 어차피 전환 자체가 세션을 날리므로 순서만 앞당기는 것이지만, 운영에서 이 명령은 전원 로그아웃이다.
**문제가 생기면** — 삭제 건수가 0 이면 이미 비어 있다. 그대로 다음으로 간다.
### 8. args 를 volatile 로 바꾼다
**목적** — `persistent-user-sessions` 를 끄고 롤아웃이 끝날 때까지 기다린다.
방법은 둘이고 매니페스트를 고치는 쪽을 권한다. 무엇이 바뀌었는지 파일에 남는다.
먼저 매니페스트를 편집기로 연다.
```bash label="[kc-lab-1] ① 저장소의 매니페스트를 연다"
vim deploy/lab/k8s/keycloak-cluster.yaml
```
`args` 줄을 이렇게 고친다.
```yaml
# 149번째 줄 근처
args: ["start", "--features-disabled=persistent-user-sessions"]
```
고친 파일을 적용한다.
```bash label="[kc-lab-1] ② 적용한다"
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
```
파일을 안 건드리고 싶으면 patch 를 쓴다.
```bash label="[kc-lab-1] 파일 대신 patch 로 바꾸는 형태"
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 label="[kc-lab-1] ③ 전환 시각을 남기고 롤아웃을 기다린다"
date '+%H:%M:%S 전환'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
**예상 결과**
```text
statefulset.apps/keycloak configured
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
```
**왜 필요한가** — `configured` 가 나와야 한다. `unchanged` 면 args 가 안 바뀌었다. 빌드 옵션이라 기동할 때 재빌드가 일어나 평소보다 오래 걸리므로 `--timeout=60s` 로 주면 멀쩡한 롤아웃을 실패로 읽는다. 전환 시각이 없으면 뒤에서 지표가 언제부터 변했는지 볼 때 인과를 못 붙인다.
**문제가 생기면** — 타임아웃이 나면 `--timeout=500s` 로 다시 치고, `logs keycloak-0` 에 빌드 진행이 보이는지 확인한다.
## 주입 검증
결과를 해석하기 전에 주입이 의도한 것만 건드렸는지 본다. 주입 ②와 ③의 검증은 그 주입을 친 단계 안에 있다 — 주입마다 검증이 따로 붙는다.
### 9. args 와 파드가 둘 다 새것인지 본다
**목적** — 선언만 바뀌고 프로세스는 그대로인 상태를 걸러 낸다.
```bash label="[kc-lab-1] args 와 파드 나이를 함께 본다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**예상 결과**
```text
["start","--features-disabled=persistent-user-sessions"]
```
`AGE` 가 방금이고 `RESTARTS` 가 `0` 인지 함께 본다.
**왜 필요한가** — StatefulSet 의 `spec` 은 바뀌었는데 파드가 옛것이면 persistent 를 재면서 volatile 이라고 적게 된다.
**문제가 생기면** — 파드 나이가 예전 값이면 롤아웃이 안 끝났다. 8번의 `rollout status` 로 돌아간다.
IP 가 바뀌었으므로 다시 잡고, 탐침 파드도 지우고 새 IP 로 다시 띄운다. 탐침의 `K0`·`K1` 은 만들 때 고정된 값이라 롤아웃 뒤에는 낡았고, 낡은 주소로 친 curl 은 아무 데도 안 닿는다. 여기서는 아직 파드 안에 지킬 파일이 없으므로 지우고 다시 만들어도 잃을 것이 없다.
```bash label="[kc-lab-1] ① 롤아웃 뒤 IP 를 다시 잡는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
kubectl -n keycloak-lab run a7-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7-probe --timeout=120s
```
### 10. 로그인 5회 뒤 DB 행 수를 센다
**목적** — args 문자열이 아니라 동작이 바뀐 것을 본다.
```bash label="[kc-lab-1] ① 한쪽 노드에만 다섯 번 로그인한다"
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'
```
```text
200 200 200 200 200
```
```bash label="[kc-lab-1] ② 같은 질의로 DB 행을 다시 센다"
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"
```
**예상 결과**
```text
=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
offline_flag | count
--------------+-------
(0 rows)
```
**왜 필요한가** — 로그인 5회가 성공했는데 DB 에 아무것도 안 남았다. 세션이 메모리에만 있다. 전환 판정은 이 질의로 한다.
**문제가 생기면** — 행이 있으면 7번의 `delete from offline_user_session` 을 건너뛰었다. 지우고 다시 로그인한다.
### 11. 캐시 엔트리 수로는 두 모드를 못 가른다는 것을 확인한다
**목적** — 다음 사람이 이 지표로 판정하지 않도록, 두 모드가 같은 값을 낸다는 것을 눈으로 본다.
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'
```
```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"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다. 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[kc-lab-1] ② 캐시 이름과 파드와 값만 세로로 늘어놓는다"
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]'
```
**예상 결과**
```text
keycloak-0 sessions 캐시 5.0 건
keycloak-1 sessions 캐시 0.0 건
```
**왜 필요한가** — persistent 였을 때와 똑같은 숫자다. `approximate_entries_unique` 는 그 노드가 소유한 엔트리만 세고 백업본을 들고 있어도 0 으로 보인다. 이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다. 두 모드를 가르는 것은 10번의 DB 행 수다.
**문제가 생기면** — 빈 결과가 오면 0건이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다.
## 관찰
앞에서 친 것과 완전히 같은 명령을 순서대로 다시 친다. A-0 · A-8 · A-1 · A-2 차례다.
### 12. A-0 을 다시 돌린다 — 교차 노드는 여전히 200 이다
**목적** — 겉보기 결과가 persistent 때와 같은지 본다.
```bash label="[kc-lab-1] 로그인하고 반대 노드에서 갱신한다"
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)"'
```
**예상 결과**
```text
=== 교차 노드 세션은 되는가 ===
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
```
**왜 필요한가** — DB 는 0건인데 `200` 이다. 경로가 완전히 달라졌는데 겉보기 답이 같다.
```text
persistent : keycloak-1 이 PostgreSQL 을 읽어서 답했다
volatile : keycloak-1 이 7800 을 통해 keycloak-0 에게 물어서 답했다
```
구별하려면 그 경로를 끊어 봐야 하고, 14번이 그것을 한다.
**문제가 생기면** — `400` 이 나오면 `/tmp/rt` 를 다시 안 채웠다. 로그인부터 다시 친다.
### 13. A-8 을 다시 돌린다 — 롤링 재시작이 곧 로그아웃이다
**목적** — 재시작 전에 발급한 토큰이 재시작 후에도 통하는지 본다.
재시작 전에 로그인해서 토큰과 `sid` 를 파드 안에 담는다.
```bash label="[kc-lab-1] ① 토큰을 담고 access token 의 클레임을 편다"
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",...}
```
```text
=== [A-8 재실행] 재시작 전 로그인 ===
sid = aVwYnzKZFFvMqD3bpSeiILuM
```
`sid` 를 적어 둔다. base64 패딩 때문에 끝이 깨져 보일 수 있고 `2>/dev/null` 이 그 불평을 지운다. `sid` 는 앞쪽에 있어서 대개 보인다.
그다음 재시작한다.
```bash label="[kc-lab-1] ② 시각을 남기고 롤링 재시작을 건다"
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
```text
statefulset.apps/keycloak restarted
partitioned roll out complete: 2 new pods have been updated...
```
파드 IP 를 다시 잡는다. 탐침은 다시 띄우지 않는다 — `/tmp/rt` 가 같이 사라진다. 그래서 새 IP 를 명령줄에 직접 넘긴다. `$K0` 는 `[kc-lab-1]` 셸의 변수이고 지금 값은 롤아웃 전 것이므로 먼저 다시 잡는다.
```bash label="[kc-lab-1] ③ 롤아웃 뒤 IP 를 다시 잡는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
```bash label="[kc-lab-1] ④ 재시작 전 토큰으로 갱신을 시도한다"
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)"'
```
인용이 세 겹이다. 바깥 작은따옴표를 닫고, 셸이 `$K0` 를 펴게 큰따옴표로 감싸고, 다시 작은따옴표를 연다. 파드 안 셸에는 이미 펴진 IP 문자열이 들어간다. 무엇이 들어가는지 `echo` 로 한 번 찍어 보는 확인은 이 실험대에서 치지 않았다(unknown).
**예상 결과**
```text
=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
keycloak-0 에서 refresh HTTP 400
--- 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
```
**왜 필요한가** — 본문을 반드시 본다. `400` 만 보면 토큰이 이상한가로 읽히지만 `Session not active` 는 서버가 그 세션을 모른다는 뜻이고, 토큰 자체는 멀쩡하다. 캐시도 함께 보면 `keycloak-1` 에 1건이 있는데, 그것은 방금 실패한 요청이 새로 만든 세션이다. 옛 세션 5건은 어디에도 없다.
**문제가 생기면** — `200` 이 나오면 args 가 아직 기본값이다. 9번으로 돌아간다.
### 14. A-1 을 다시 돌린다 — 이번에는 세션 공유가 깨진다
**목적** — 7800 과 57800 을 양방향으로 버리고 교차 노드가 끊기는지 본다.
두 노드에 각각 규칙을 넣는다. 규칙의 `-d` 는 그 노드에 있는 파드의 IP 다.
```bash label="[kc-lab-1] ① 양쪽 노드에 raw DROP 을 넣는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(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` |
**뒤의 두 줄은 중단 절차처럼 나눠 치면 안 된다.** 거기서는 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 가 들어간다. `$K0` 는 `[kc-lab-1]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[kc-lab-1]` 에서 펴서 보내므로 이 두 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 `echo "$K0"` 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다.
NetworkPolicy 대신 `iptables` 를 쓰는 까닭은 A-1 에서 나왔다. NetworkPolicy 는 conntrack 의 ESTABLISHED 를 못 뚫어서 이미 붙어 있는 7800 연결이 계속 산다.
```text
패킷 도착
├─▶ raw PREROUTING ← conntrack 보다 먼저. 여기서 끊는다
├─▶ conntrack: ESTABLISHED 면 통과
└─▶ NetworkPolicy 평가 ← 여기까지 오지 않는다
```
`raw` 테이블은 CNI 가 안 쓰는 테이블이라 규칙이 밀려나지도 않는다. 57800 을 같이 막는 까닭은 장애 감지 채널 FD_SOCK2 가 `bind_port + 50000` 을 쓰기 때문이다. 7800 만 막으면 장애 감지가 살아 있어 분단이 어중간해진다.
주입이 걸렸는지 양쪽 카운터를 둘 다 본다.
```bash label="[kc-lab-1] ② 두 노드의 규칙과 카운터를 본다"
sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
모양은 이렇고 숫자는 환경마다 다르다.
```text
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` 가 0 이면 패킷이 그 경로로 안 오는 것이고 분단은 안 만들어졌다. A-5 가 이 함정에 두 번 빠졌다.
분단이 성립할 때까지 25초 간격으로 몇 번 친다.
```bash label="[kc-lab-1] ③ 두 노드가 각각 아는 멤버 수를 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
```text
차단 적용 (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초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다.
split brain 은 DB 한 줄로 확인한다.
```bash label="[kc-lab-1] ④ 디스커버리 테이블의 코디네이터를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
```text
name | ip | coord
------------------+-----------------+-------
keycloak-0-30843 | 10.42.1.99:7800 | t
keycloak-1-48749 | 10.42.0.46:7800 | t
```
`coord = t` 가 둘이면 분단이고 정상일 때는 하나다.
대조군을 먼저 재고 시험군을 잰다. 대조군은 같은 노드에서 갱신한다.
```bash label="[kc-lab-1] ⑤ 대조군 — 로그인한 노드에서 갱신한다"
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 label="[kc-lab-1] ⑥ 시험군 — 새로 로그인해서 반대 노드에서 갱신한다"
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)"'
```
**예상 결과**
```text
=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) ===
keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군
--- 시험군 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
```
**왜 필요한가** — 대조군을 같이 재야 차단이 모든 것을 망가뜨린 게 아니라 교차 노드만 끊었다고 말할 수 있다. 시험군은 매번 새로 로그인해서 새 토큰으로 한다. refresh token 이 회전하기 때문이다.
```text
persistent : 세션 ── PostgreSQL ──▶ 양쪽이 본다 7800 무관
volatile : 세션 ── 클러스터(7800) ─▶ 상대에게 간다 7800 필수
```
「이 실험이 가르는 것」에서 미뤄 둔 판정이 여기서 난다. 통념은 24 이전에서 맞고, 틀린 것은 자료가 아니라 버전을 확인하지 않고 적용하는 것이다.
**문제가 생기면** — 교차 노드가 계속 `200` 이면 차단이 한쪽만 걸렸다. 두 노드 카운터를 둘 다 본다.
다음으로 넘어가기 전에 차단을 푼다. 명령은 전제와 되돌리기 절의 두 형태와 같다. 양쪽 `vendor_cluster_size` 가 `2` 로 돌아와야 한다 — 분단이 남아 있으면 다음 결과가 DB 때문인지 분단 때문인지 구별되지 않는다.
### 15. A-2 를 다시 돌린다 — 새 로그인은 되는데 refresh 가 안 된다
**목적** — DB 를 내리고 두 경로를 잰다.
내리기 전에 로그인해서 `/tmp/rt` 를 채운다. **12번의 명령을 쓰지 않는다** — 그 블록은 로그인한 다음 곧바로 반대 노드에서 refresh 까지 해서 방금 받은 토큰을 소모한다. refresh token 은 한 번 쓰면 회전하므로 `/tmp/rt` 에는 이미 쓴 값이 남고, DB 를 내린 뒤의 `500` 이 DB 때문인지 재사용 때문인지 구별되지 않는다. 로그인만 하고 끝나는 13번의 ① 을 쓴다.
```bash label="[kc-lab-1] ① 로그인만 해서 /tmp/rt 를 채운다"
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'
```
그다음 DB 를 내린다.
```bash label="[kc-lab-1] ② 시각을 남기고 DB 를 0 replica 로 내린다"
date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
`delete pod` 이 아니라 `scale --replicas=0` 인 까닭은 Deployment 가 지운 파드를 곧바로 새로 만들기 때문이다. DB 가 없는 구간을 원하는 만큼 유지할 수 있어야 두 경로를 다 잰다.
두 경로를 차례로 친다.
```bash label="[kc-lab-1] ③ 캐시를 가진 노드에서 refresh"
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 label="[kc-lab-1] ④ 새 로그인"
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"'
```
**예상 결과** — 이 실험대에서는 이 값이 나왔다. 아래 ①② 는 증거 파일의 번호이고 위 명령의 ③④ 와 차례가 같다.
```text
① 캐시를 가진 노드에서 refresh HTTP 500
② 새 로그인 HTTP 200
```
persistent 에서는 순서가 거꾸로였다. 새 로그인이 `500` 이었는데, 세션을 DB 에 써야 했기 때문이다. 그 쓰기가 없어지니 로그인이 통과한다.
```text
로그인에 필요한 것
├─ realm 설정 → Infinispan `realms` 캐시에 있다
├─ 사용자 자격 → `users` 캐시에 있다
└─ 세션 저장 → volatile 이므로 메모리
→ DB 없이 완결된다
```
**왜 필요한가** — 여기서 잰 두 숫자를 그대로 표에 옮기면 틀린 표가 된다. 같은 설정에서 캐시 온도만으로 답이 셋으로 갈리고, 이 값은 마침 롤아웃 뒤 로그인을 몇 번 했고 refresh 는 안 한 상태에서 쟀다. 캐시 온도는 `kubectl get` 어디에도 안 나오는 상태라 한 번 재고 넘어가면 조건을 모르는 채 결과만 남는다. 셋을 갈라 재는 절차는 A-7a 에 있고, A-7 이 남긴 「refresh 가 500 인 이유는 `REVOKED_TOKEN` 조회일 것」이라는 가설은 거기서 틀린 것으로 확정됐다. 실제 문장은 `CLIENT_SCOPE_CLIENT` 조회다.
**문제가 생기면** — `200 / 200` 이 나오면 캐시가 이미 더워졌다. 틀린 측정이 아니라 다른 상태를 잰 것이므로 A-7a 로 간다. 로그인이 `400 unauthorized_client` 면 완전 냉시동이고, 그것도 A-7a 가 가른다.
마지막으로 DB 를 되살린다.
```bash label="[kc-lab-1] ⑤ DB 를 다시 올린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
```
```text
deployment.apps/postgres scaled
deployment "postgres" successfully rolled out
```
volatile 이 「DB 없이 돌아간다」는 뜻은 아니다. realm 과 사용자와 클라이언트와 취소 토큰은 여전히 DB 에 있고, 세션만 메모리로 옮겼다.
## 복구와 원상복구 확인표
순서가 있다. `iptables` 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, `args` 를 되돌린다.
```bash label="[kc-lab-1] ① 두 노드의 raw 규칙을 확인한다"
sudo iptables -t raw -L PREROUTING -n
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
```
```bash label="[kc-lab-1] ② DB 파드를 본다"
kubectl -n keycloak-lab get pods -l app=postgres
```
`Running` 이 아니면 `scale deployment/postgres --replicas=1` 을 친다.
```bash label="[kc-lab-1] ③ args 를 기본값으로 되돌린다"
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
매니페스트를 고쳤다면 파일도 같이 되돌린다. 안 그러면 다음에 `apply` 할 때 volatile 로 다시 간다.
```bash label="[kc-lab-1] ④ 매니페스트의 변경을 확인하고 되돌린다"
git diff deploy/lab/k8s/keycloak-cluster.yaml
git checkout -- deploy/lab/k8s/keycloak-cluster.yaml
```
```text
=== persistent 모드로 원복 ===
statefulset.apps/keycloak configured
partitioned roll out complete: 2 new pods have been updated...
```
`args` 문자열만 보고 끝내지 않는다. 새 IP 로 탐침을 다시 띄우고 로그인을 한 번 한 다음 DB 행을 센다. 원복도 롤아웃이므로 여기서도 파드 주소가 바뀌었다.
```bash label="[kc-lab-1] ⑤ 새 IP 로 탐침을 다시 띄운다"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7-probe --timeout=120s
```
```bash label="[kc-lab-1] ⑥ 로그인을 한 번 한다"
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"'
```
```bash label="[kc-lab-1] ⑦ 로그인 뒤 온라인 세션 행을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
```text
["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
```
앞에서 테이블을 비웠으므로 여기서 세는 값은 방금 만든 세션 하나다. 0 이면 아직 volatile 이다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| args | `kubectl -n keycloak-lab get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].args}'` | `["start"]` |
| 매니페스트 | `git diff deploy/lab/k8s/keycloak-cluster.yaml` | 출력 없음 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| DB | `kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
| 동작 | 로그인 뒤 `select count(*) ...` | 세션 행이 생긴다 |
| iptables | `sudo iptables -t raw -L PREROUTING -n` (두 노드) | 규칙 없음 |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `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 label="[kc-lab-1] ⑧ 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
```
## 막히면
아래는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `rollout status` 가 타임아웃 | 빌드 옵션이라 재빌드가 일어난다 | `--timeout=500s` 로 다시. `logs keycloak-0` 에 빌드 진행 |
| `apply` 가 `unchanged` | args 를 안 고쳤거나 다른 파일을 고쳤다 | `get statefulset ... -o jsonpath='{...args}'` 로 실제 값 |
| 전환했는데 DB 에 행이 있다 | `delete` 를 건너뛰었다. 옛 행은 안 지워진다 | `delete from offline_user_session` 후 다시 로그인 |
| 캐시가 `5 / 0` 이라 전환이 안 된 것 같다 | 두 모드가 같은 값을 낸다 | 판정은 DB 행 수로 한다 |
| 차단했는데 `cluster_size` 가 계속 2 | 규칙이 안 걸렸거나 `pkts` 가 0 | `iptables -t raw -L PREROUTING -n -v` 의 카운터 |
| `cluster_size` 결과가 비었다 | 측정 실패다. 스크립트가 빈 값을 뱉었다 | 손으로 다시 친다. 빈 값은 판정에서 뺀다 |
| 교차 노드가 계속 `200` | 차단이 한쪽만 걸렸다 = 단방향 | 두 노드 카운터를 둘 다 본다 |
| 재시작 뒤 아무 데도 안 닿는다 | 파드 IP 가 바뀌었다 | `get pod -o jsonpath='{.status.podIP}'` 다시 |
| refresh 가 `400` 인데 이유를 모르겠다 | 본문을 안 봤다 | `-o /dev/null` 을 빼고 `Session not active` 인지 본다 |
| A-2 재실행이 `200 / 200` 이 나온다 | 캐시가 이미 더워졌다. 틀린 게 아니다 | 조건부다 — A-7a |
| 로그인이 `400 unauthorized_client` | 완전 냉시동이다. 클라이언트 조회조차 캐시에 없다 | 이것도 조건부 — A-7a |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
| 다음 실험 결과가 이상하다 | 원복을 안 했다 | 확인표를 전부 통과시킨다 |
## 무엇이 관측이고 무엇이 아닌가
이 실험대가 실제로 본 것(observed)은 전환 전 `args` 가 `["start"]` 이고 파드 IP 가 `10.42.1.94` 와 `10.42.0.45` 였던 것, `DELETE 151`, 전환 뒤 `args` 가 `["start","--features-disabled=persistent-user-sessions"]` 인 것, 로그인 5회 뒤 `(0 rows)` 와 캐시 `5.0`/`0.0`, 교차 노드 refresh `HTTP 200`, 재시작 전 `sid = aVwYnzKZFFvMqD3bpSeiILuM` 와 재시작 뒤 `HTTP 400` 및 `Session not active`, 재시작 뒤 캐시 `1.0`, 분단 대기 시계열 다섯 줄, 대조군 `200` 과 시험군 `400`, DB 정지 뒤 `① 500` 과 `② 200`, 원복 뒤 `["start"]` 와 `DB 온라인 세션: 1 건` 과 외부 `200`, 비밀번호 길이 `19`, 기능 목록의 `persistent-user-sessions[:v1]` 이다.
가이드가 미검증으로 표시한 것(unknown)은 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력이다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인도 이 실험대에서 치지 않았다.
측정이 샌 곳이 하나 있다. `cluster_size` 시계열의 빈 값과 값이 하나뿐인 줄인데, 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다.
DB 정지 뒤의 `200 / 500` 은 조건부다. 캐시 온도에 따라 `400 / 400` 이나 `200 / 200` 도 나오고, 셋을 가르는 절차는 A-7a 에 있다. A-7 이 세운 원인 가설도 A-7a 가 틀린 것으로 확정했다.
이 절차가 재지 않은 것은 volatile 상태에서 노드를 추가했을 때 복제 트래픽이 어떻게 늘어나는지다. 파드가 둘뿐이라 그것을 볼 수 없다.
<!-- body:end -->
@@ -0,0 +1,855 @@
---
id: 21dce25a-a165-47dc-bb40-2ed9f6f9efea
kind: SETUP
slug: reproduce-a7a-volatile-cause
title: 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/21dce25a-a165-47dc-bb40-2ed9f6f9efea/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
- name: persistent-user-sessions
version: v1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다
PostgreSQL 문장 로깅을 켜고 volatile 상태의 로그인과 refresh 가 각각 SQL 을 몇 개 쏘는지 화면에서 직접 보는 절차다. 이어서 재현 셋을 `rollout restart` 로 갈라 치면 같은 설정에서 `400``500``200` 이 차례로 나온다. 약 40분.
## 관계
- **같은 설정이 캐시 온도만으로 세 가지 답을 냈다**
이 절차가 재현 A·B·C 로 갈라 잰 것을 그 기록이 결론으로 적는다.
- **persistent-user-sessions 가 세션의 거처를 정한다**
여기서 끄는 그 기능이 무엇을 바꾸는지는 그 기록이 설명한다.
- **persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다**
이 절차가 확정하는 `500` 이 거기서 나왔다. 그쪽은 주입이 args 와 `iptables` 와 DB 정지이고 계기가 교차 노드 응답 코드이며, 여기는 주입이 문장 로깅과 args 와 DB 정지이고 계기가 표식과 문장 로그다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
주입이 셋이라 검증도 셋이고, 표식이 로그에 들어갔는지를 확인하지 않으면 뒤의 구간 자르기가 통째로 헛돈다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 `kubectl` 로 친다. 이 절차에는 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl``sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
이 편의 시각은 UTC 다. 증거의 `11:18:49` 는 KST 로 `20:18` 이고 같은 순간이다. PostgreSQL 컨테이너가 UTC 로 로그를 찍기 때문이고, 로그 시각과 `date` 를 견줄 때 이걸 잊으면 9시간을 헤맨다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
| 탐침 파드 | `a7a-probe``curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 켜는 것 | `log_statement = 'all'` · 반드시 `pg_reload_conf()` 까지 |
| 끄는 기능 | `--features-disabled=persistent-user-sessions` |
| 표식 | `MARK_TEST` · `MARK_LOGIN_START` · `MARK_LOGIN_END` · `MARK_REFRESH_START` · `MARK_REFRESH_END` · `MARK_R1`~`MARK_R_END` |
| 소음 | `JGROUPS_PING` 폴링이 5초마다 로그를 채운다 |
터미널은 둘을 연다. 하나는 표식과 요청용, 하나는 로그 관찰용이다.
## 이 실험이 가르는 것
A-7 은 이렇게 끝났다.
> 측정은 확실하지만 원인은 확정하지 못했다. 유력한 후보는 `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
그럴듯하고, 틀렸다.
```text
가설을 세우는 것 → 괜찮다
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
```
「refresh 가 어느 테이블 때문에 실패하는가」는 Keycloak 소스를 읽지 않고도 답할 수 있다. DB 가 실제로 받은 문장을 보면 된다. 확정해 보니 원인만 틀린 게 아니었다 — 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다.
이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 로그인이 SQL 을 0개 쏘는 것, refresh 가 쏘는 딱 한 문장의 이름이 `CLIENT_SCOPE_CLIENT` 인 것, 그 문장이 첫 refresh 에만 나오는 것, `REVOKED_TOKEN` 이 한 번도 안 나오는 것, 같은 설정에서 `400``500``200` 이 전부 나오는 것, 그리고 실패한 SQL 을 Keycloak 로그가 직접 지목하는 것.
## 전제와 되돌리기
앞선 구축 단계 `05-keycloak` 이 끝나 있어야 한다. A-7 을 먼저 한다 — 이 절차는 A-7 이 남긴 가설을 확정하는 것이고, 거기서 본 `500` 에서 출발한다. A-3 에서 문장 로깅을 해 봤으면 같은 기법이다.
주입이 셋이고 복구도 셋이다.
- PostgreSQL 문장 로깅을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
- Keycloak 을 volatile 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
- PostgreSQL 을 여러 번 내렸다 올린다 → 마지막에 올라와 있어야 한다
실험대에서만 한다. 중간에 그만두려면 복구 절을 위에서부터 그대로 친다.
표식을 넣는 방식에서 이 절차가 원 실행과 갈라진다. 원 실행은 표식을 셸 함수로 감쌌다.
```bash label="[kc-lab-1] 이 실험대는 이렇게 했다 (observed)"
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
```
짧고 편한데 출력을 `/dev/null` 로 버린다. 표식이 실제로 로그에 들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이고, 로깅이 안 켜져 있었다면 표식 없는 로그를 한참 뒤에 `awk` 로 자르다가 알게 된다.
따라 하는 사람은 표식을 한 줄씩 손으로 넣는다. 느리지만 그 즉시 보이고, 안 보이면 그 즉시 안다. 아래 절차가 전부 그 형태다.
```bash label="[kc-lab-1] 따라 하는 사람은 이 형태로 친다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_TEST'"
```
## 주입 전에 같은 명령으로 먼저 본다
```text
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
```
### 1. 파드 셋이 전부 떠 있는지 본다
**목적** — 이 절차가 내렸다 올릴 `postgres` 가 지금 있는지 확인한다.
```bash label="[kc-lab-1] 네임스페이스의 파드를 넓게 본다"
kubectl -n keycloak-lab get pods -o wide
```
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
```text
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` 가 특히 그렇다. 이 절차는 그것을 세 번 내렸다 올린다.
**문제가 생기면** — `postgres` 가 없으면 `scale deployment/postgres --replicas=1` 부터 친다.
### 2. 문장 로깅이 지금 꺼져 있는지 본다
**목적** — 지금 쌓이는 로그가 이 실험 것인지 앞 실험 것인지 가른다.
```bash label="[kc-lab-1] 현재 설정값을 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**예상 결과**
```text
log_statement
---------------
none
```
**왜 필요한가** — `all` 이면 앞 실험이 켜 둔 채 끝낸 것이고, 지금 쌓인 로그가 어느 실험 것인지 구별할 수 없다.
**문제가 생기면** — `all` 이 나오면 먼저 끄고 로그가 한 바퀴 돌 때까지 기다린 뒤에 시작한다.
### 3. 지금 args 를 적어 둔다
**목적** — 복구에서 되돌릴 문자열을 확보한다.
```bash label="[kc-lab-1] 컨테이너 args 를 그대로 찍는다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**예상 결과**
```text
["start"]
```
**왜 필요한가** — 복구 단계가 이 값 그대로 되돌린다.
**문제가 생기면** — 이미 `--features-disabled=persistent-user-sessions` 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 그것부터 되돌린다.
### 4. 탐침 파드를 StatefulSet 밖에 띄운다
**목적** — Keycloak 을 여러 번 재시작해도 죽지 않는 요청 장치를 세운다.
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어서 `kubectl exec keycloak-0 -- curl` 은 `exit 127` 로 끝난다.
```bash label="[kc-lab-1] ① 탐침을 띄우고 Ready 를 기다린다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
```text
19
```
```bash label="[kc-lab-1] ③ 탐침 안에 값이 들어갔는지 본다"
kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
```
**예상 결과** — IP 가 보이고 `PW길이` 가 0 이 아니다.
**왜 필요한가** — 명령줄에 평문 비밀번호를 쓰면 파드 안 `ps` 에도 셸 히스토리에도 남는다. 원래 실험의 재현 절차에 그 형태가 그대로 적혀 있었다.
**문제가 생기면** — `PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
### 5. 로그가 지금 무엇으로 차 있는지 본다
**목적** — 켜기 전의 로그를 한 번 봐 두고, 켠 뒤의 소음과 견준다.
```bash label="[kc-lab-1] 마지막 20줄을 본다"
kubectl -n keycloak-lab logs deploy/postgres --tail=20
```
**예상 결과** — 조용하다. 여기까지는 에러만 찍힌다.
**왜 필요한가** — 다음 절에서 로깅을 켜면 JGroups 가 5초마다 하는 `JGROUPS_PING` 폴링이 로그를 계속 채운다. 그 소음을 먼저 봐 두면 나중에 `grep -v JGROUPS_PING` 으로 거르는 까닭을 안다.
**문제가 생기면** — 지금 SQL 이 줄줄이 나오면 로깅이 이미 켜져 있다. 2번으로 돌아간다.
## 주입
주입 셋을 차례로 넣는다. 셋 다 되돌리는 명령을 먼저 읽어 둔다.
### 6. PostgreSQL 문장 로깅을 켠다
**목적** — 서버가 받은 모든 SQL 을 로그에 찍게 한다.
되돌리는 명령을 먼저 읽어 둔다.
```bash label="[kc-lab-1] ① 되돌리는 명령 — 먼저 읽어 둔다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
```
```bash label="[kc-lab-1] ② 문장 로깅을 켜고 설정을 다시 읽힌다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
```
**예상 결과** — `ALTER SYSTEM` 과 `pg_reload_conf` 가 차례로 돌고, 곧 로그가 차기 시작한다.
**왜 필요한가** — 애플리케이션을 고치지 않고 「이 요청이 DB 를 어떻게 쓰는지」를 밖에서 볼 수 있다. 이것 없이 하면 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.
**문제가 생기면** — 로그가 안 차면 `pg_reload_conf()` 가 안 돌았다. `alter system` 은 `postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.
### 7. Keycloak 을 volatile 로 바꾼다
**목적** — 세션을 메모리로 옮겨 A-7 이 본 조건을 만든다.
되돌리는 명령을 먼저 읽어 둔다.
```bash label="[kc-lab-1] ① 되돌리는 명령 — 먼저 읽어 둔다"
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
```bash label="[kc-lab-1] ② persistent-user-sessions 를 끈다"
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"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
파드 IP 가 바뀌었으므로 탐침 파드를 다시 띄운다.
```bash label="[kc-lab-1] ③ 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
**예상 결과** — 롤아웃이 `partitioned roll out complete` 로 끝나고 탐침이 Ready 가 된다.
**왜 필요한가** — 빌드 옵션이라 기동할 때 재빌드가 일어나 오래 걸린다. 그래서 `--timeout=500s` 를 준다.
**문제가 생기면** — 타임아웃이 나면 같은 명령을 다시 치고 `logs keycloak-0` 에 빌드 진행이 보이는지 본다.
### 8. PostgreSQL 을 내린다
**목적** — DB 가 없는 구간을 만들어 캐시가 무엇을 대신하는지 본다.
```bash label="[kc-lab-1] DB 를 0 replica 로 내리고 파드가 사라질 때까지 기다린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**예상 결과** — `postgres` 파드가 목록에서 사라진다.
**왜 필요한가** — 세 재현마다 한 번씩, 모두 세 번 내린다. 각 재현에서 내리는 시점이 다르고 그 시점이 곧 캐시 온도를 정한다.
**문제가 생기면** — 파드가 안 사라지면 `--timeout` 을 늘려서 다시 기다린다. `delete pod` 은 쓰지 않는다 — Deployment 가 곧바로 새로 만든다.
## 주입 검증
주입이 셋이라 검증도 셋이다. 로깅이 켜졌는지, 표식이 로그에 들어가는지, volatile 전환이 동작으로도 바뀌었는지를 따로 본다.
### 9. 로깅이 실제로 켜졌고 로그가 차기 시작했는지 본다
**목적** — 설정값과 실제 출력을 둘 다 확인한다.
```bash label="[kc-lab-1] ① 설정값을 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
```text
log_statement
---------------
all
```
```bash label="[kc-lab-1] ② 로그가 차는지 본다"
kubectl -n keycloak-lab logs deploy/postgres --tail=10
```
**예상 결과**
```text
2026-09-04 11:17:40.112 UTC [214] LOG: execute <unnamed>: select ... from JGROUPS_PING ...
```
**왜 필요한가** — `JGROUPS_PING` 이 계속 나오는 것이 앞에서 예고한 소음이고, 이게 안 보이면 로깅이 안 켜졌다.
**문제가 생기면** — `none` 이 나오면 `pg_reload_conf()` 를 다시 친다.
### 10. 표식이 로그에 들어가는지 본다
**목적** — 구간을 자를 수 있는 상태인지 확인한다.
```bash label="[kc-lab-1] ① 표식을 하나 넣는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_TEST'"
```
```bash label="[kc-lab-1] ② 그 표식이 로그에 있는지 본다"
kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
```
**예상 결과**
```text
2026-09-04 11:18:40.102 UTC [301] LOG: statement: select 'MARK_TEST'
```
**왜 필요한가** — `statement: select 'MARK_TEST'` 가 보이면 이제 표식과 표식 사이만 잘라 볼 수 있다.
**문제가 생기면** — 안 보이면 9번의 로깅 확인으로 돌아간다.
### 11. volatile 전환을 args 와 동작으로 둘 다 본다
**목적** — 선언과 동작이 같이 바뀌었는지 확인한다.
```bash label="[kc-lab-1] args · 로그인 응답 코드 · DB 행 수를 이어서 본다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
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"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**예상 결과**
```text
volatile 전환 확인
args: ["start","--features-disabled=persistent-user-sessions"]
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
```
**왜 필요한가** — 세 가지가 다 맞아야 한다. 로그인이 `200` 인데 행이 안 생기는 것으로 판정한다.
**문제가 생기면** — 행 수가 0 이 아니면 옛 행이 남아 있다. A-7 처럼 `delete from offline_user_session` 을 먼저 하고 다시 잰다.
문장 로그가 지금 요청을 잡고 있는지도 본다.
```bash label="[kc-lab-1] 최근 60초의 로그 끝을 본다"
kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
```
이 시점에서는 거의 `JGROUPS_PING` 뿐일 텐데, 그게 이 실험의 첫 발견이다. 지금은 「내 요청이 어디 있는지 모르겠다」로만 보이고, 구간을 나눠야 보인다.
## 관찰
표식 → 요청 → 표식 순으로 치고 `awk` 로 그 사이를 자른다.
### 12. 로그인이 무슨 SQL 을 쏘는지 본다
**목적** — 로그인 한 번이 DB 에 무엇을 보내는지 센다.
```bash label="[kc-lab-1] ① 표식 · 로그인 · 표식을 차례로 친다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_START'"
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"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_END'"
```
```text
rt 1188 bytes
```
```bash label="[kc-lab-1] ② 로그를 파일로 받아 구간을 자른다"
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
```
**예상 결과**
```text
11:18:49.461 statement: select 'MARK_LOGIN_START'
11:18:49.743 statement: select 'MARK_LOGIN_END'
↑ 사이에 아무것도 없다
```
**왜 필요한가** — 두 줄뿐이고 로그인은 SQL 을 0개 쏜다. realm 과 사용자와 클라이언트가 전부 Infinispan 캐시에 있고 volatile 이라 세션 쓰기도 없다. `awk '/A/,/B/'` 는 A 가 나온 줄부터 B 가 나온 줄까지 출력한다. 로그를 파일로 먼저 받는 까닭은 같은 로그를 여러 구간으로 반복해서 잘라 볼 것이기 때문이다.
**문제가 생기면** — `rt 1 bytes` 면 파싱이 실패했고, 그 상태로 다음을 하면 빈 토큰을 보내고 엉뚱한 오류를 보게 된다. `cat /tmp/tok` 으로 본문을 본다.
### 13. refresh 가 쏘는 한 문장의 이름을 읽는다
**목적** — A-7 의 가설이 지목한 테이블이 실제로 나오는지 본다.
```bash label="[kc-lab-1] ① 표식 · refresh · 표식을 차례로 친다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_START'"
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)"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_END'"
```
```bash label="[kc-lab-1] ② 그 구간을 자른다"
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
```
**예상 결과**
```text
11:18:52.009 statement: select 'MARK_REFRESH_START'
11:18:52.137 statement: BEGIN
11:18:52.137 execute <unnamed>/C_107:
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
11:18:52.148 execute S_2: COMMIT
11:18:52.253 statement: select 'MARK_REFRESH_END'
```
세 가지를 본다 — `BEGIN` 과 `COMMIT` 사이에 `select` 가 하나뿐인 것, 테이블 이름이 `CLIENT_SCOPE_CLIENT` 인 것, `parameters` 줄의 `$2 = 'f'`.
가설이 지목한 테이블이 정말 없는지 직접 센다.
```bash label="[kc-lab-1] ③ 그 구간에서 REVOKED_TOKEN 을 센다"
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
```
```text
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
```
**왜 필요한가** — `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 이 세션 계열 테이블을 의심한 것이 자연스러웠지만 빗나갔다.
그 UUID 가 어느 클라이언트인지 궁금하면 물어본다. UUID 는 렐름을 만들 때 정해지므로 실험대마다 다르다. ②가 자른 구간의 `parameters` 줄에 있는 `$1` 값을 그대로 옮겨 넣는다.
```bash label="[kc-lab-1] ④ 그 UUID 가 어느 클라이언트인지 묻는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select id, client_id from client where id='{{CLIENT_UUID}}'"
```
이 실험대의 값은 `131a9912-b578-4b9c-b16a-97518704077e` 였다(observed).
`admin-cli` 가 나오면 방금 친 요청의 클라이언트가 맞다.
**문제가 생기면** — 구간에 표식이 두 번 나오면 로그를 여러 번 받아 구간이 겹쳤다. `--tail` 을 줄이거나 새 표식 이름을 쓴다.
### 14. 그 조회가 한 번뿐인 것을 본다
**목적** — 첫 refresh 가 캐시를 채우고 이후로는 DB 를 보지 않는다는 것을 확인한다.
```bash label="[kc-lab-1] ① 표식을 사이사이에 넣으며 refresh 를 돈다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R1'"
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"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R2'"
```
같은 모양으로 `MARK_R3` 과 `MARK_R_END` 까지 두 번 더 한다. 매번 `/tmp/rt` 를 다시 채운다 — refresh token 은 회전하고, 옛것을 계속 쓰면 나오는 오류가 무효화 때문인지 재사용 때문인지 구별되지 않는다.
```bash label="[kc-lab-1] ② 표식 넷 사이를 통째로 자른다"
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
```
**예상 결과**
```text
연속 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 를 언제 내리느냐에 따라 답이 달라진다.
**문제가 생기면** — `400 Session not active` 가 나오면 옛 refresh token 을 재사용했다. 매번 `/tmp/rt` 를 갱신한다.
### 15. 재현 A — 완전 냉시동이면 로그인부터 400 이다
**목적** — 캐시가 전부 빈 상태에서 DB 를 내렸을 때의 답을 잰다.
캐시는 Keycloak 을 재시작해야만 식는다.
```text
Infinispan 캐시 = 프로세스 메모리
└─ 파드가 살아 있는 한 안 식는다
└─ 그래서 세 재현 사이마다 rollout restart 를 한다
```
이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다. 이미 더워진 캐시에서 계속 재게 되므로 A 와 B 를 재도 C 의 답이 나오고, 「A-7 이 틀렸다」는 엉뚱한 결론에 이른다.
```bash label="[kc-lab-1] ① 재시작하고 곧바로 DB 를 내린다"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
재시작과 DB 정지 사이에 아무 요청도 보내지 않는다. 한 번이라도 로그인하면 캐시가 더워져서 이건 재현 B 가 된다. 파드 IP 가 바뀌었으므로 탐침을 다시 띄운 다음 로그인을 본문까지 본다.
탐침의 `K0` 는 만들 때 고정된 값이라 재시작 뒤에는 낡았다. 지우고 새 IP 로 다시 만든다. 이 블록을 건너뛰면 뒤의 curl 이 없는 주소로 가고, 그 침묵을 「DB 가 없어서 실패」로 읽게 된다.
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
```bash label="[kc-lab-1] ③ 로그인을 본문과 함께 본다"
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"'
```
**예상 결과**
```text
로그인 400 {"error":"unauthorized_client",
"error_description":"Unexpected error when authenticating client"}
```
`unauthorized_client` 이고 `invalid_grant` 가 아니다. 세션 문제가 아니라 클라이언트를 못 찾았다. 왜인지는 Keycloak 로그가 직접 말한다.
```bash label="[kc-lab-1] ④ 실패한 SQL 을 Keycloak 로그에서 뽑는다"
kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
```
```text
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` 가 그 두 쌍만 뽑는다. A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었는데 냉시동에서는 클라이언트 조회조차 캐시에 없어서 로그인부터 실패한다.
**문제가 생기면** — 아무것도 안 나오면 `--tail` 을 늘리거나 `grep -i 'JDBC exception'` 으로 먼저 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다. 로그인이 `200` 이 나오면 재시작 후 요청을 한 번이라도 보낸 것이므로 이 재현을 처음부터 다시 한다.
### 16. 재현 B — 로그인만 한 번 하면 refresh 가 500 이다
**목적** — A-7 이 본 그 조건을 그대로 만든다.
```bash label="[kc-lab-1] ① DB 를 살리고 다시 재시작한다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
탐침을 새 IP 로 다시 띄운 뒤 로그인 한 번만 한다. 15번과 같은 이유로 여기서도 탐침을 다시 만든다.
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
```bash label="[kc-lab-1] ③ 로그인 한 번으로 캐시를 절반만 데운다"
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 가 된다. 8번의 DB 정지를 친 다음에 refresh 한다.
```bash label="[kc-lab-1] ④ DB 가 없는 상태에서 refresh 한다"
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)"'
```
**예상 결과**
```text
로그인 200
refresh 500 {"error":"unknown_error"}
```
실패한 SQL 을 15번과 같은 `grep -oE` 로 뽑으면 이렇게 나온다.
```text
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=?]
```
**왜 필요한가** — 13번에서 문장 로깅이 「이 문장을 쏜다」를 보여 줬고 여기서는 「이 문장이 실패했다」가 나온다. 둘이 만나면 가설이 아니라 확정이 된다. `500 unknown_error` 인 까닭도 이제 안다 — 세션은 멀쩡하고, 토큰을 조립하다가 DB 가 없어서 못 만든 것을 Keycloak 이 사용자 오류로 분류할 방법이 없어서 `unknown_error` 를 준다.
**문제가 생기면** — `200 / 200` 이 나오면 로그인 뒤 refresh 를 미리 했다. 로그인 한 번만 하고 DB 를 내린다.
### 17. 재현 C — 미리 세 번 갱신해 두면 둘 다 200 이다
**목적** — 캐시가 완전히 더운 상태의 답을 잰다.
DB 를 살리고, 재시작하고, 탐침을 새로 만들고, 로그인하고, refresh 를 3회 미리 돌린 뒤 DB 를 내린다. 앞 절들의 명령을 그대로 다시 친다.
```bash label="[kc-lab-1] ① DB 를 살리고 다시 재시작한다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
파드가 새로 떴으므로 `K0` 가 낡았다. 탐침도 그 값을 `--env` 로 박아 뒀으니 같이 다시 만든다.
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
새 탐침에는 `/tmp/rt` 가 없다. 16번의 ③ 과 같은 명령으로 다시 만든다.
```bash label="[kc-lab-1] ③ 로그인해서 /tmp/rt 를 새로 만든다"
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"'
```
**여기서 재현 C 가 끊긴다.** 다음에 와야 할 것은 DB 를 내리기 전에 refresh 를 세 번 돌려 캐시를 마저 채우는 단계인데, **그 세 번을 치는 명령이 원본 가이드에 없다**(unknown). 가이드는 「refresh 3회를 미리 돌린 뒤」라고 쓰고 그 세 번의 명령도, 회전하는 refresh token 을 `/tmp/rt` 에 매번 다시 쓰는 형태도 남기지 않았다. 14번의 ① 이 표식 사이에서 refresh 를 한 번 돌리며 `/tmp/rt` 를 갱신하는 형태를 갖고 있지만, 그것을 세 번 돌리는 것이 가이드가 말한 그 3회와 같은지는 확인되지 않았다. **이 단계를 채우지 못하면 아래 ④⑤ 를 쳐도 재현 B 와 같은 상태이고 `500` 이 나온다.**
그 세 번을 돌렸다고 보고, 8번과 같은 명령으로 DB 를 내린다.
```bash label="[kc-lab-1] ④ DB 를 0 replica 로 내리고 파드가 사라질 때까지 기다린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
```bash label="[kc-lab-1] ⑤ 로그인과 refresh 를 이어서 친다"
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)"'
```
**예상 결과**
```text
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
로그인 200
refresh 200 ← A-7 의 표와 정반대다
```
**왜 필요한가** — 같은 설정, 같은 명령, 세 개의 답이 나왔다.
| 캐시 상태 | 로그인 | 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건) |
무엇이 다른지는 `kubectl get` 어디에도 안 나온다. 캐시 온도는 보이지 않는 상태이고, A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의 함정이다.
```text
volatile + DB 정지의 결과
= "무엇을 하느냐"가 아니라
"그 경로가 이미 캐시를 채웠느냐"
```
persistent 기본값에는 이 조건부성이 없다. 세션 자체를 DB 에 쓰므로 DB 가 없으면 캐시 온도와 무관하게 실패한다. 이것은 volatile 고유의 성질이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.
**문제가 생기면** — 세 재현이 전부 `200/200` 이면 재시작을 건너뛰어 캐시가 계속 더웠다. 재현마다 `rollout restart` 를 넣는다.
## 복구와 원상복구 확인표
셋을 순서대로 되돌린다. DB 가 살아 있어야 나머지가 된다.
```bash label="[kc-lab-1] ① DB 를 올리고 Ready 까지 기다린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
```
문장 로깅을 끈다. 잊으면 다음 실험이 전부 오염된다.
```bash label="[kc-lab-1] ② 문장 로깅을 끄고 값을 다시 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
```text
log_statement
---------------
none
```
왜 급한가 — A-3 은 수백 건의 로그인을 최대한 빨리 돈다. `log_statement='all'` 이면 로그인 하나에 SQL 열 몇 줄씩 쌓이고, 로그가 폭주하고 디스크 입출력이 늘어 크래시 타이밍 자체가 달라진다. 다음 실험의 측정값이 이 설정 때문에 바뀐다.
```bash label="[kc-lab-1] ③ args 를 기본값으로 되돌린다"
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
`args` 문자열만 보고 끝내지 않는다. 탐침을 새 IP 로 띄우고 로그인을 한 번 한 다음 행을 센다.
```bash label="[kc-lab-1] ④ 로그인 뒤 온라인 세션 행을 센다"
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 로 돌아온 것이고, 원래 재현 절차도 마지막에 이 한 줄을 둔다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
| args | `get statefulset keycloak -o jsonpath='{...containers[0].args}'` | `["start"]` |
| DB | `kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
| 동작 | 로그인 뒤 `select count(*) ...` | 세션 행이 생긴다 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `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 label="[kc-lab-1] ⑤ 탐침과 임시 파일을 치운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
rm -f /tmp/pg.log
```
## 막히면
아래는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 표식이 로그에 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 |
| 표식 사이가 `JGROUPS_PING` 으로 가득하다 | 정상이다. 5초마다 폴링한다 | `grep -v JGROUPS_PING` |
| 표식이 두 번 나온다 | 로그를 여러 번 받아 구간이 겹쳤다 | `--tail` 을 줄이거나 새 표식 이름을 쓴다 |
| 로그 시각이 9시간 어긋난다 | 컨테이너 로그가 UTC 다 | `date -u` 와 비교한다 |
| refresh 가 `400 Session not active` | 옛 refresh token 을 재사용했다 | 매번 `/tmp/rt` 를 갱신 |
| `rt 1 bytes` | 파싱 실패. 빈 토큰을 보내게 된다 | `cat /tmp/tok` 으로 본문 확인 |
| 세 재현이 전부 `200/200` | 재시작을 건너뛰어 캐시가 계속 더웠다 | 재현마다 `rollout restart` |
| 재현 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` |
| 다음 실험의 세션이 안 살아남는다 | volatile 로 둔 채 끝냈다 | 로그인 뒤 행 수 확인 |
## 무엇이 관측이고 무엇이 아닌가
이 실험대가 실제로 본 것(observed)은 volatile 전환 확인의 세 줄(`args` · 로그인 `200` · 행수 `0`), 로그인 구간의 표식 두 줄 `11:18:49.461` 과 `11:18:49.743` 및 그 사이 SQL 0건, refresh 구간의 다섯 줄과 `CLIENT_SCOPE_CLIENT` 문장 전문과 파라미터 `$1 = '131a9912-b578-4b9c-b16a-97518704077e'` 및 `$2 = 'f'`, `REVOKED_TOKEN` 0건, 연속 refresh 3회의 표식 네 줄과 SQL 0건, 재현 A 의 `400 unauthorized_client` 와 `select ce1_0.ID from CLIENT ...` 실패 SQL, 재현 B 의 `200` 과 `500 unknown_error` 및 `CLIENT_SCOPE_CLIENT` 실패 SQL, 재현 C 의 `200` 과 `200`, 비밀번호 길이 `19`, 표식 시험의 `statement: select 'MARK_TEST'` 다.
이 편에는 가이드가 미검증으로 표시한 명령이 하나도 없다(unknown 이 0건이다). 표식을 감싼 셸 함수 `m()` 은 원 실행이 실제로 썼고(observed), 그것을 한 줄씩 손으로 푸는 형태가 가이드의 권고다.
시각 표기는 UTC 다. 증거 파일과 위 인용이 전부 UTC 이고 KST 로는 `20:1820:24` 이며, PostgreSQL 컨테이너가 UTC 로 찍기 때문이다.
A-7 에서 틀린 것으로 확정된 것이 둘이다. 원인 테이블을 `REVOKED_TOKEN` 으로 본 가설, 그리고 「volatile 이면 DB 없이 로그인된다」는 서술이다. 냉시동에서는 로그인부터 실패한다.
이 절차가 재지 않은 것은 캐시가 얼마나 오래 더운지다. `CLIENT_SCOPE_CLIENT` 결과의 캐시 만료 시간을 모르므로 한참 뒤에 다시 재면 또 다른 답이 나올 수도 있다. 그것까지 확인하려면 재현 C 뒤에 시간을 두고 같은 시험을 반복해야 한다.
<!-- body:end -->
@@ -0,0 +1,702 @@
---
id: 0b64d23a-f82a-44b4-ad54-e079578977c4
kind: SETUP
slug: reproduce-a8-rolling-restart
title: 롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/0b64d23a-f82a-44b4-ad54-e079578977c4/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-8
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다
`rollout restart` 로 파드 둘을 교체한 뒤에도 토큰이 아직 통하는지 보는 절차다. 로그인해서 refresh token 과 `sid` 를 탐침 파드 안 파일에 담아 두고 교체한 다음 그 토큰을 쓴다. 외부 응답 시계열도 함께 잰다. 전 구간 15~20분이고 되돌릴 것이 없다.
## 관계
- **롤링 재시작은 세션을 남기고 캐시만 지웠다**
이 절차가 재는 것을 그 기록이 결론으로 적는다.
- **persistent-user-sessions 가 세션의 거처를 정한다**
세션이 살아남는 까닭이 그 기능이고, 그것이 꺼져 있으면 이 절차는 정반대 답을 낸다.
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
여기의 가용성 루프가 그 점검에서 실행 가능한 형태로 고쳐진 명령 가운데 하나다.
- **persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다**
같은 시험을 옛 기본값 위에서 치면 `200``400 Session not active` 로 바뀐다.
- **세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다**
「세션은 DB 에 있고 캐시는 사본」이라는 모델을 거기서 세웠고, 여기서 파드를 통째로 갈아 그 모델을 확인한다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 `kubectl` 로 친다. 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl``sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
터미널은 둘을 연다. 하나는 가용성 감시용이라 루프가 도는 동안 붙잡혀 있고, 하나는 재시작과 관찰용이다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
| 탐침 파드 | `a8-probe``curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 전제 args | `["start"]` — 플래그가 붙어 있으면 이 절차가 아니다 |
| 가용성 루프 | 5초 간격 48회 · `--max-time 4` · 외부 진입점으로 |
| 무중단의 전제 | replica 2 와 readiness 프로브 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr``grep` 으로 자른다 |
## 이 실험이 가르는 것
운영에서 가장 자주 겪는 작업이다. 장애가 아니라 정상 배포인데도 사용자가 로그아웃되면 그건 사고다.
```text
배포한다 → 파드가 교체된다 → 프로세스 메모리가 사라진다
└─ 세션이 거기 있었다면?
```
A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을 세웠다. 그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 하고, 틀리다면 배포가 곧 전원 로그아웃이다.
| | 예측 |
|---|---|
| A-0 모델 (persistent) | 재시작해도 세션 생존 |
| 옛 방식 (volatile) | 재시작하면 전원 로그아웃 |
둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다. 그리고 이 절차는 가용성도 같이 잰다 — 세션이 살아도 재시작 중에 서비스가 끊기면 그것대로 문제가 된다.
이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 파드가 전부 교체되는 동안 외부가 계속 `200` 인 것, 재시작 전에 발급한 토큰이 재시작 후에도 통하는 것, DB 세션 수가 그대로인 것, 캐시만 0 으로 비워지는 것, 클러스터가 스스로 다시 붙는 것, 그리고 「무중단」이 관측 해상도에 달려 있다는 것.
## 전제와 되돌리기
앞선 구축 단계 `05-keycloak``06-observability` 가 끝나 있어야 한다. A-0 을 먼저 하면 좋다 — 「세션은 DB 에 있고 캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
이건 파괴적이지 않다. 그래서 더 조심한다. `rollout restart` 는 정상 작업이고 되돌릴 것이 없으며 잘못돼도 클러스터가 스스로 회복한다. 그 대신 함정이 다르다 — 재는 것이 「안 깨졌나」라서 측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽고, 원래 실행이 실제로 그랬다.
다른 실험과 겹치지 않게 한다. 롤링 재시작 중에 다른 주입이 들어가 있으면 무엇 때문에 무엇이 일어났는지 구별되지 않는다.
정말 되돌려야 하면 이 명령이 있다. 다만 중간에 `rollout status``Ctrl-C` 로 끊어도 롤아웃 자체는 계속 진행되므로 끝날 때까지 두는 편이 낫다.
```bash label="[kc-lab-1] 직전 리비전으로 되돌린다"
kubectl -n keycloak-lab rollout undo statefulset/keycloak
```
## 주입 전에 같은 명령으로 먼저 본다
```text
파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
```
### 1. 파드와 나이와 replica 수를 적어 둔다
**목적** — 재시작 전의 `AGE` 를 확보하고 replica 가 2 인지 확인한다.
```bash label="[kc-lab-1] 파드를 노드와 함께 넓게 본다"
kubectl -n keycloak-lab get pods -o wide
```
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
```text
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` 를 적어 둔다. 재시작 후 이 값이 초 단위로 바뀌는 것으로 「정말 재시작됐다」를 판정한다. `keycloak` 파드가 둘인 것도 함께 본다. 그것이 무중단의 전제이고 하나면 반드시 끊긴다.
**왜 필요한가** — `rollout restart` 는 파드를 삭제하고 새로 만들기 때문에 `RESTARTS` 가 안 오른다. 재시작 여부를 `RESTARTS` 로 보면 아무 일도 안 일어났다고 읽게 된다.
**문제가 생기면** — `keycloak` 파드가 하나뿐이면 이 절차의 가용성 측정은 성립하지 않는다.
```bash label="[kc-lab-1] 두 파드 IP 를 변수에 담는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
### 2. args 가 기본값인지 확인한다
**목적** — `persistent-user-sessions` 가 켜져 있는 상태에서 재는지 본다.
```bash label="[kc-lab-1] 컨테이너 args 를 그대로 찍는다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**예상 결과**
```text
["start"]
```
**왜 필요한가** — 플래그가 없으므로 `persistent-user-sessions` 가 기본으로 켜져 있다. `--features-disabled=persistent-user-sessions` 가 붙어 있으면 이 절차는 정반대 결과를 낸다.
**문제가 생기면** — 플래그가 보이면 앞 실험이 원복하지 않고 끝냈다. 그것부터 되돌린 뒤에 시작한다.
### 3. DB 세션 수를 적어 둔다
**목적** — 재시작 후에 견줄 값을 확보한다.
```bash label="[kc-lab-1] 온라인 세션과 offline token 을 나눠 센다"
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"
```
**예상 결과**
```text
DB 세션 수: 151
```
**왜 필요한가** — 재시작 후 같은 값이 나오는지가 뒤의 판정에 들어간다. 숫자는 환경마다 다르고 관리 API 호출도 세션을 만들기 때문에 개수에는 소음이 섞인다. 그래서 이 절차는 개수 말고 특정 `sid` 하나를 따로 추적한다.
**문제가 생기면** — `(0 rows)` 가 나오면 세션이 없거나 volatile 이다. 2번으로 돌아간다.
### 4. 상주 탐침 파드를 StatefulSet 밖에 띄운다
**목적** — 재시작을 넘어 토큰을 들고 있을 장치를 만든다.
```text
토큰을 어디에 두나
├─ Keycloak 파드 안 → 같이 죽는다. 못 쓴다
├─ 내 셸 변수 → 되지만 화면·히스토리에 남는다
└─ 단독 탐침 파드의 /tmp → StatefulSet 과 무관하게 산다 ★
```
```bash label="[kc-lab-1] ① 탐침을 띄우고 Ready 를 기다린다"
kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
```
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
```text
19
```
```bash label="[kc-lab-1] ③ 탐침 안에 값이 들어갔는지 본다"
kubectl -n keycloak-lab exec a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
**예상 결과** — 두 IP 가 보이고 `PW길이` 가 0 이 아니다.
**왜 필요한가** — Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어서 `kubectl exec keycloak-0 -- curl` 은 `exit 127` 로 끝난다.
**문제가 생기면** — `PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 ① 부터 다시 한다. `--rm` 이 없는 상주 파드라 지우지 않으면 같은 이름이 그대로 있어 ① 이 `AlreadyExists` 로 거절되고, 이 절차를 두 번째 칠 때도 같은 곳에서 걸린다.
```bash label="[kc-lab-1] ④ 탐침을 지우고 ① 로 돌아간다"
kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
```
### 5. 토큰과 sid 를 파드 안에 담고 길이를 확인한다
**목적** — 재시작을 넘겨 쓸 값을 파일에 남기고, 그 파일이 비어 있지 않은지 본다.
이 단계에 이 실험의 함정이 있다.
```bash label="[kc-lab-1] ① 로그인해서 refresh token 과 sid 를 파일로 남긴다"
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)"'
```
**예상 결과**
```text
=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
sid = XLcgQWRiJrTkuNZcJsNeT_2j
```
두 값이 다 채워졌는지 본다.
| 출력 | 뜻 |
|---|---|
| `rt 1188 bytes / sid XLcg...` | 정상 |
| `rt 1 bytes` | 빈 문자열에 개행만. 파싱 실패 |
| `sid` 가 비어 있음 | base64 패딩 때문에 잘렸다. sid 없이 진행하고 판정은 개수로 본다 |
**왜 필요한가** — 원래 실행이 실제로 빠진 함정이 여기 있다. 첫 재현 절차는 `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽었는데 `/tmp/rt` 를 만드는 줄이 빠져 있었다. 그러면 빈 문자열이 `refresh_token=` 으로 전송되는데, 그래도 `400` 이 아니라 통과한 것처럼 보였고 아무 에러도 안 났다. 이 실험의 판정이 「재시작 후 refresh 가 `200` 인가」이므로, 빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로 거짓이 된다. `wc -c` 한 번이 이 시험 전체를 지킨다.
못 미더우면 파일을 직접 본다.
```bash label="[kc-lab-1] ② 파일 크기와 앞 40바이트를 본다"
kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
kubectl -n keycloak-lab exec a8-probe -- head -c 40 /tmp/rt ; echo
```
```text
-rw-r--r-- 1 curl_use curl_gro 1188 Sep 4 13:19 /tmp/rt
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
```
`/tmp/rt` 의 크기가 네 자리이고 내용이 `eyJ` 로 시작한다. `eyJ` 는 base64 로 인코딩된 `{"` 이고 JWT 는 전부 이렇게 시작한다.
**문제가 생기면** — `rt 1 bytes` 면 `cat /tmp/tok` 으로 응답 본문을 본다.
### 6. 대조군 — 재시작 전에 refresh 가 되는 것을 본다
**목적** — 뒤의 `200` 이 무엇과 견준 값인지 확보한다.
```bash label="[kc-lab-1] ① 같은 노드에서 갱신해 본다"
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)"'
```
**예상 결과**
```text
200
```
이 refresh 로 토큰이 회전했다. `/tmp/rt` 의 값은 이제 이미 쓴 토큰이라 다시 채워야 하고, 안 채우면 뒤의 `400` 이 재시작 때문인지 재사용 때문인지 구별되지 않는다. 5번의 ① 과 같은 명령을 그대로 다시 친다.
```bash label="[kc-lab-1] ② 다시 로그인해서 두 파일을 새로 만든다"
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` 가 최종 추적 대상이다. 7번부터 끝까지 이 값을 쓰므로 적어 둔다. ① 의 `200` 은 대조군이고, 그 대조군을 잡느라 소비한 토큰을 ② 가 메운다.
**문제가 생기면** — ① 에서 `400` 이 나오면 5번의 파일 확인으로 돌아간다. ② 의 출력이 `rt 1 bytes` 면 5번의 ② 로 파일을 직접 본다.
### 7. 그 세션이 지금 DB 에 있는지 sid 로 본다
**목적** — 재시작 전의 행 상태를 기록한다.
원 가이드의 질의는 `sid` 를 셸 치환으로 집어넣어 `psql -c` 문자열 안에 `kubectl exec` 이 한 번 더 들어간다. 따라 하는 사람은 방금 적어 둔 `sid` 를 그대로 친다 — 앞 명령이 이미 그 값을 화면에 보여 줬고, 명령 하나가 한 가지 일만 한다. 이 두 단계 형태는 이 실험대에서 치지 않았다(unknown).
```bash label="[kc-lab-1] ① sid 를 화면에서 읽는다"
kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid
```
```bash label="[kc-lab-1] ② 읽은 값을 그대로 넣어 행을 찾는다"
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='{{SID}}'"
```
`sid` 는 로그인할 때마다 새로 생긴다. 이 실험대의 값은 `XLcgQWRiJrTkuNZcJsNeT_2j` 였다(observed).
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
```text
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495513
(1 row)
```
행이 1개 있고 `created_on` 과 `last_session_refresh` 가 같다. 아직 갱신한 적이 없다.
**왜 필요한가** — 재시작 후에 이 행이 그대로 있고 `last_session_refresh` 만 올라가는 것이 뒤의 판정이다.
**문제가 생기면** — `(0 rows)` 가 나오면 `sid` 를 잘못 옮겼거나 그 세션이 이미 사라졌다. 5번부터 다시 한다.
### 8. 캐시와 클러스터 크기를 미리 본다
**목적** — 재시작 후 0 이 되는 값을 먼저 확보한다.
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
모양은 이렇고 값은 환경마다 다르다.
```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"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다. 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[kc-lab-1] ② 파드와 값만 세로로 늘어놓는다"
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` 다. 세션 캐시 엔트리 수도 같은 형태로 보면 0 이 아닌 값이 나온다.
**왜 필요한가** — 재시작 후 캐시가 0 이 되고 클러스터 크기가 다시 `2` 로 돌아오는 것이 뒤의 판정이다.
**문제가 생기면** — 빈 결과가 오면 0 이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다.
## 주입
### 9. 가용성 감시를 먼저 띄우고 재시작한다
**목적** — 재시작 중 외부 응답을 5초 간격으로 기록하면서 파드를 교체하고, 그 사이에 엔드포인트가 어떻게 움직이는지 본다.
두 번째 터미널에서 루프를 돌린다. 재시작보다 먼저 시작해야 끊김 구간을 놓치지 않는다.
```bash label="[kc-lab-1 · 두 번째 터미널] ① 5초 간격으로 48번 외부를 친다"
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초 간격보다 짧게 잡은 것인데, 타임아웃이 간격보다 길면 요청이 밀려 시계열이 어긋난다.
첫 번째 터미널에서 재시작한다. `rollout restart` 는 바로 돌아오고, 파드 교체는 그 뒤에 백그라운드로 진행된다.
```bash label="[kc-lab-1] ② 시각을 남기고 롤링 재시작을 건다"
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
```
**엔드포인트는 여기서 봐야 보인다.** 파드가 서비스에서 빠졌다 돌아오는 것은 롤아웃이 도는 동안에만 나타나고, 끝난 뒤에 치면 ready 주소가 늘 둘로 나온다. 두 번째 터미널은 ① 의 루프에 붙잡혀 있으므로 이 터미널에서 몇 번 반복해서 친다. 찍힌 것을 어떻게 읽는지는 15번에서 적는다.
```bash label="[kc-lab-1] ③ 롤아웃이 도는 동안 엔드포인트를 몇 번 본다"
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
```
그다음 롤아웃이 끝날 때까지 기다린다. 이 명령은 끝날 때까지 터미널을 붙잡는다.
```bash label="[kc-lab-1] ④ 롤아웃이 끝날 때까지 기다린다"
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
```
**예상 결과** — 아래는 두 터미널의 출력이 한 파일에 섞여 기록된 것이다. `200` 이 가용성 루프, `Waiting for...` 가 `rollout status` 다. 이 실험대는 ②④ 를 한 블록으로 연달아 쳤고 아래는 그때의 출력이다. 사이에 ③ 을 끼우면 `rollout status` 가 그만큼 늦게 시작하므로 `Waiting for` 줄 수가 이와 다를 수 있다.
```text
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...
```
`0 out of 2` 에서 `1 out of 2` 를 거쳐 `complete` 로 한 번에 하나씩 가고 그 사이사이에 `200` 이 계속 찍힌다.
**왜 필요한가** — ① 을 ② 보다 늦게 띄우면 첫 파드가 내려가는 구간을 통째로 놓친다. ③ 도 마찬가지로 ④ 뒤로 밀면 놓친다. 시각도 반드시 적어 둔다.
**문제가 생기면** — ④ 가 타임아웃이면 파드가 Ready 를 못 받고 있다. `describe pod` 의 Events 와 `logs --previous` 를 본다. ④ 를 `Ctrl-C` 로 끊어도 롤아웃 자체는 계속 진행된다.
## 주입 검증
### 10. 파드가 진짜 바뀌었는지 AGE 로 본다
**목적** — 「세션이 살아남았다」가 의미를 갖는 조건을 확인한다.
```bash label="[kc-lab-1] 파드 나이와 재시작 카운터를 본다"
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**예상 결과**
```text
=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0 1/1 Running 0 44s
keycloak-1 1/1 Running 0 66s
```
세 가지를 본다. `AGE` 가 초 단위인 것(앞에서 `2d` 였던 것이 `44s` 다), 두 나이가 다른 것(`44s` 와 `66s` 의 22초 차이가 롤링의 간격이고, 둘이 같으면 동시에 내려간 것이라 무중단이 아니다), 그리고 `RESTARTS` 가 여전히 `0` 인 것.
**왜 필요한가** — `rollout restart` 는 파드를 지우고 새로 만들므로 재시작 카운터가 새 파드에서 0 부터 시작한다. 판정에 `RESTARTS` 를 쓰면 안 된다는 것이 여기서 드러난다.
**문제가 생기면** — `AGE` 가 예전 값이면 롤아웃이 안 끝났다. 9번의 ④ 로 돌아간다.
파드 IP 가 바뀌었으므로 다시 잡는다. 탐침 파드는 다시 띄우지 않는다 — `/tmp/rt` 와 `/tmp/sid` 가 같이 사라진다. 탐침 안의 `K0` 환경변수는 낡았으므로 새 IP 를 명령줄로 넘긴다.
```bash label="[kc-lab-1] 새 파드 IP 를 다시 잡는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
### 11. 가용성 시계열을 읽고 표본 수를 센다
**목적** — 끊김이 관측됐는지 보고, 그 관측이 무엇까지 말할 수 있는지 정한다.
두 번째 터미널의 출력을 읽는다.
```text
200 200 200 200 200 200 200 200 200
```
**예상 결과** — `200` 이 9개이고 비200 이 없다.
루프는 48회로 잡았는데 남은 표본은 9개다. 원 기록이 그 차이를 설명하지 않는다(unknown) — 루프를 중간에 끊었는지, 기록에 앞부분만 옮겼는지 알 수 없다. 표본 수를 셀 때는 루프 횟수가 아니라 화면에 실제로 찍힌 개수를 센다.
**왜 필요한가** — 「무중단」이라고 쓰기 전에 표본 수를 본다.
```text
9개 표본 × 5초 간격 = 약 45초를 9번 들여다본 것
└─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다
```
실제로 더 촘촘히 재니 끊김이 나왔다. 후속 작업에서 1초 간격과 3초 타임아웃으로 다른 전환을 재 본 값이 이렇다.
```text
200 ×24 000 200 ×19
```
`000` 은 서버 오류가 아니라 `--max-time 3` 타임아웃이다. 파드 전환 순간 요청 하나가 3초를 넘겼다.
| 쓰면 안 되는 문장 | 정확한 문장 |
|---|---|
| 「무중단이었다」 | 「5초 해상도에서 끊김이 관측되지 않았다」 |
더 촘촘히 보고 싶으면 루프를 이렇게 바꾼다. 가이드가 미검증으로 표시한 형태다(unknown).
```bash label="[kc-lab-1 · 두 번째 터미널] 1초 간격 150회로 더 촘촘히 잰다"
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
```
**문제가 생기면** — 루프가 전부 `000` 이면 잘못된 URL 을 치고 있다. `curl -v` 로 한 번 본다.
## 관찰
### 12. 본 시험 — 재시작 전 토큰이 아직 통하는가
**목적** — 파드 안에 보관해 둔 토큰을 새 파드 IP 로 보낸다.
셸 인용이 세 겹이 되는 형태이고, 가이드는 여기에 다른 형태를 제시하지 않는다. 탐침을 다시 띄우면 토큰이 사라지기 때문이다.
```bash label="[kc-lab-1] 재시작 전 토큰으로 갱신을 시도한다"
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)"'
```
**예상 결과**
```text
=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
keycloak-0 에서 refresh HTTP 200
```
`200` 이고 본문에 새 토큰이 들어 있다. 파드가 통째로 바뀌었는데 세션이 살아 있다. 새로 뜬 프로세스는 이 세션을 메모리에서 알던 것이 아니라 DB 에서 읽었다.
**왜 필요한가** — `400` 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다. 대조군 시험 뒤에 `/tmp/rt` 를 다시 안 채웠거나, `rt 1 bytes` 를 놓쳤거나, args 에 `--features-disabled=persistent-user-sessions` 가 있거나 셋 중 하나다. 셋 다 아니면 그때 결론을 의심한다.
**문제가 생기면** — 아무 데도 안 닿으면 파드 IP 가 바뀐 것을 명령에 반영하지 않았다. 10번의 IP 잡기를 다시 한다.
### 13. DB 행의 두 시각을 견준다
**목적** — 응답 코드만이 아니라 쓰기까지 정상인지 본다.
적어 둔 `sid` 를 넣어 7번의 ② 와 같은 질의를 다시 친다.
```bash label="[kc-lab-1] ① 같은 행을 다시 찾는다"
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='{{SID}}'"
```
**예상 결과**
```text
=== [4] DB 에 그 세션이 남아 있는가 ===
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
(1 row)
```
두 숫자의 차이를 본다.
```text
1788495577 - 1788495513 = 64초
│ │
│ └─ 재시작 전에 세션이 만들어진 시각
└─ 재시작 후의 refresh 가 기록된 시각
```
두 값은 유닉스 시각(초)이라 사람이 읽는 형태로 보려면 이렇게 친다.
```bash label="[kc-lab-1] ② 두 유닉스 시각을 사람이 읽는 형태로 바꾼다"
date -d @1788495513 ; date -d @1788495577
```
**왜 필요한가** — `200` 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다. A-1 에서 실제로 그런 일이 있었다. 여기서는 새 파드가 DB 에서 세션을 읽었고 갱신 시각을 DB 에 되썼으므로 그 가능성이 없다.
**문제가 생기면** — `last_session_refresh` 가 안 올랐으면 본 시험을 하기 전에 조회했다. 순서는 refresh 를 먼저 하고 조회한다.
전체 세션 수도 함께 본다.
```bash label="[kc-lab-1] ③ 온라인 세션 전체를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
```text
전체 온라인 세션: 151 (재시작 전 151)
```
3번에서 적어 둔 값과 같다. 한 건도 안 잃었다. `sid` 하나가 살아남은 것과 전체가 살아남은 것은 다른 주장이라 둘 다 본다. 관리 API 호출이 세션을 만들기 때문에 몇 건 늘어날 수는 있고, 크게 줄었다면 그게 문제가 된다.
### 14. 캐시가 비워지고 클러스터가 다시 붙는 것을 본다
**목적** — 재시작이 무엇을 지우고 무엇을 남겼는지 가른다.
```bash label="[kc-lab-1] 캐시 엔트리 수와 클러스터 크기를 이어서 본다"
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]'
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**예상 결과**
```text
=== [5] 캐시는 어떻게 되었는가 ===
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
```
세 가지를 본다. 캐시가 0 인 것(프로세스 메모리라 재시작에 사라졌다), `keycloak-1` 의 1건(방금 refresh 를 처리하며 새로 담은 값이므로 0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다), 그리고 `cluster_size` 가 다시 `2` 인 것.
**왜 필요한가** — A-0 의 모델이 여기서 그대로 확인된다.
```text
재시작 전: 캐시 N건 + DB 151건
재시작 후: 캐시 0건 + DB 151건 ← 진실은 DB 에 있다
```
캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다. 룩어사이드 캐시의 성질이다.
**문제가 생기면** — `cluster_size` 가 `1` 에서 안 올라오면 클러스터가 다시 안 붙었다. 파드 로그에서 멤버 수를 본다.
### 15. 무중단이 되는 까닭을 엔드포인트에서 본다
**목적** — 파드가 서비스에서 언제 빠지고 언제 돌아오는지 본다.
```text
StatefulSet 롤링 재시작
├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
│ └─ 이 동안 keycloak-0 이 전부 받는다
├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
└─ keycloak-0 종료 → ... (반복)
```
실제로 그렇게 움직이는지는 재시작 중에 쳐야 보인다. 그 명령이 9번의 ③ 이므로 여기서 읽는 것은 그때 화면에 찍힌 값이다. 지금 다시 쳐도 롤아웃이 이미 끝났으므로 ready 주소는 둘로만 나온다.
**예상 결과** — 재시작 중에는 ready 주소가 하나로 줄었다가 둘로 돌아온다.
**왜 필요한가** — 한 번에 하나씩 내리므로 항상 최소 하나는 Ready 이고, readiness 프로브가 이 전환을 맞춰 준다. A-2 에서 장애를 격리하는 장치로 본 그 메커니즘이 여기서는 정상 작업을 안전하게 만든다.
| 무중단의 조건 | 빠지면 |
|---|---|
| replica ≥ 2 | 하나뿐이면 내리는 동안 아무도 안 받는다 |
| readiness 프로브 | 아직 기동 중인 파드로 트래픽이 간다 |
둘 다 있어야 성립하고, 이 실험대는 파드가 2개라서 됐다.
**문제가 생기면** — `kubectl get endpoints` 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜨므로 `endpointslice` 를 본다.
## 복구와 원상복구 확인표
주입이 정상 작업이었으므로 되돌릴 것이 없다. 정리만 한다.
```bash label="[kc-lab-1] 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
```
남겨 두면 7200초 뒤에 스스로 끝나지만, 그 안에 다른 실험을 하면 네임스페이스에 정체 모를 파드가 하나 있는 상태가 된다. 지운다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| Service | `kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 |
| 클러스터 뷰 | `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'"` | 재시작 전과 비슷한 값 |
| 탐침 파드 | `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` |
## 막히면
아래는 이 실험대가 실제로 겪은 증상이다. 마지막 줄만 A-2·A-3 에서 겪은 것을 옮겼다 — 탐침 파드를 같은 방식으로 띄우므로 여기서도 그대로 걸린다.
| 증상 | 원인 | 확인 |
|---|---|---|
| refresh 가 `200` 인데 뭔가 이상하다 | `/tmp/rt` 가 비어 있다. 빈 토큰인데 통과한 것처럼 보인다 | `wc -c < /tmp/rt` |
| refresh 가 `400 Session not active` | 대조군 시험 뒤에 `/tmp/rt` 를 안 채웠다. 이미 쓴 토큰이다 | 새로 로그인해서 다시 담는다 |
| refresh 가 `400` 인데 토큰은 맞다 | args 가 volatile 이다 | `get statefulset ... args`. 그건 A-7 |
| 재시작 후 아무 데도 안 닿는다 | 파드 IP 가 바뀌었다 | `get pod -o jsonpath='{.status.podIP}'` 다시 |
| 탐침을 다시 띄웠더니 토큰이 없다 | `/tmp/rt` 가 파드와 함께 사라졌다 | 탐침은 재시작 내내 유지한다 |
| `RESTARTS` 가 0 이라 재시작이 안 된 것 같다 | `rollout restart` 는 파드를 교체한다 | `AGE` 로 본다 |
| `rollout status` 가 타임아웃 | 파드가 Ready 를 못 받는다 | `describe pod` 의 Events, `logs --previous` |
| 가용성 루프에 `000` 이 섞인다 | `--max-time` 초과. 서버 오류가 아니다 | 간격보다 짧은 타임아웃인지 |
| 가용성 루프가 전부 `000` | 루프가 잘못된 URL 을 친다 | `curl -v` 로 한 번 본다 |
| 세션 수가 크게 줄었다 | 다른 실험이 세션을 지웠거나 volatile 이다 | args 와 DB 세션 수를 다시 |
| DB 행의 `last_session_refresh` 가 안 올랐다 | 본 시험을 하기 전에 조회했다 | 순서: refresh → 조회 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
| `a8-probe` 를 다시 못 만든다 | 앞선 실행의 파드가 그 이름으로 남아 있다 | `delete pod a8-probe --ignore-not-found` |
## 무엇이 관측이고 무엇이 아닌가
이 실험대가 실제로 본 것(observed)은 재시작 전 DB 세션 `151` 과 `sid = XLcgQWRiJrTkuNZcJsNeT_2j`, 비밀번호 길이 `19`, `rollout status` 와 가용성 루프가 섞인 출력 전문, 재시작 뒤 파드 나이 `44s` 와 `66s` 및 `RESTARTS 0`, 가용성 시계열의 `200` 아홉 개, 재시작 전 토큰의 `HTTP 200`, DB 행의 `1788495513` 에서 `1788495577` 로의 변화, 전체 세션 `151 (재시작 전 151)`, 캐시 `0.0` 과 `1.0` 및 `cluster_size 2.0`, 후속 작업의 `200 ×24 000 200 ×19` 다.
가이드가 미검증으로 표시한 것(unknown)은 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력과 1초 간격·3초 타임아웃 루프다. `sid` 를 화면에서 읽어 질의에 직접 넣는 두 단계 형태도 이 실험대에서 치지 않았다.
원래 실행이 실제로 빠졌던 곳이 하나 있다. 첫 재현 절차에 `/tmp/rt` 를 만드는 줄이 없어서 빈 문자열이 `refresh_token=` 으로 전송됐는데, `400` 이 아니라 통과한 것처럼 보였고 아무 에러도 안 났다.
해상도에 걸린 주장이 하나 있다. 「무중단」이 아니라 「5초 해상도에서 끊김이 관측되지 않았다」이고 표본은 9개다. 1초 간격으로 잰 후속 작업은 다른 조건에서 `000` 을 하나 잡았다. 루프를 48회로 돌렸는데 표본이 9개인 까닭은 원 기록에 없어서 여기서도 못 적는다(unknown).
이 절차가 재지 않은 것이 셋이다. replica 1 에서 어떻게 되는지(반드시 끊긴다고 적었지만 재지 않았다), 5초보다 짧은 끊김, 그리고 캐시가 0 에서 다시 차는 데 걸리는 시간이다. 「첫 접근만 느려진다」고 썼지만 그 느림을 재지 않았고, A-6 이 인접한 주제다.
<!-- body:end -->
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
---
kind: CASE
slug: nginx-does-not-overwrite-a-header-it-never-sets
title: nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b4
assets:
- key: b4-header-trust-boundary
file: ../../../final/assets/b4-header-trust-boundary/b4-header-trust-boundary.svg
evidence:
- ../../../final/evidence/raw/b4-edge-authorization__01-header-handling.txt
- ../../../final/evidence/raw/followup__03-b4-role-propagation.txt
---
# nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다
밖에서 붙인 위조 헤더가 앱까지 그대로 도착했다. nginx 는 자기가 `proxy_set_header` 로 설정한 이름만 덮어쓰기 때문이다. 다만 같은 위조 헤더로 JWT 를 요구하는 경로를 찔렀을 때는 401 이었다. IdP 에서 클레임을 바꿔도 12회 요청·약 6.4초 동안 옛 값이 갔다.
## 관계
- **믿기 전에 그 헤더를 먼저 지운다**
이 실험에서 위조 헤더가 통과한 조건을 반복 적용할 기준으로 편 것이다.
- **백채널 로그아웃은 양쪽 다 없었다**
엣지와 IdP 가 만든 인증 결과가 앱까지 가는 같은 경로를 다루고, 그쪽은 로그아웃 통지가 끊긴 경우다.
## 문제
Forward-Auth 구조에서 앱은 자기가 토큰을 검증하지 않고 프록시가 넣어 준 헤더를 읽어 인가한다. 그러면 그 헤더를 어디까지 믿을 수 있는지가 정해져야 하는데, 앞선 작업이 남긴 열린 질문 Q4 가 그것이었다. 설계로는 답이 나오지 않아 실제로 돌려 봐야 했다.
nginx 를 앞에 두었으니 밖에서 같은 이름의 헤더를 붙여 보내도 프록시 단계에서 덮어쓰일 것으로 봤다. 그 예측이 틀렸다.
## 결론
nginx 가 덮어쓰는 헤더 : proxy_set_header 로 자기가 설정한 이름만
설정하지 않은 이름 : 클라이언트가 보낸 값이 앱까지 간다
먼저 지우지 않았을 때 위조 헤더가 앱까지 도착하는가 : o
같은 위조 헤더를 JWT 를 요구하는 경로에 보냈을 때 : 401
헤더만 읽어 인가하는 앱이 위조와 정상을 구별할 수 있는가 : x
지우는 줄을 넣어 본 적 : 없다. 넣은 뒤를 재지 않았다
IdP 에서 클레임을 바꿨을 때는 이렇게 나왔다.
옛 값이 간 구간 : 12회 요청 · 약 6.4초
새 값이 온 시점 : Redis 세션을 지워 재인증시킨 뒤
cookie-refresh 설정 : 없음
## 검증 환경
실험대 : 베어메탈 test-server 한 대 위에 VM 두 대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
엣지 : 호스트 nginx 가 Let's Encrypt TLS 를 끝내고 traefik 으로 넘긴다
인증 프록시 : oauth2-proxy 두 replica
위조를 보낸 경로 : 도착한 헤더를 그대로 되돌려주는 echo 앱. 앞에 oauth2-proxy 가 없다
클레임 반영을 잰 경로 : oauth2-proxy 뒤에 세션을 만들어 놓고 쟀다. 쿠키가 HttpOnly 라 브라우저에서 쳤다
분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af
실행일 : 2026-09-04 14:23 KST(헤더 주입)와 07:5107:53 UTC(클레임 반영)
## 재현 조건
1. 도착한 헤더를 그대로 되돌려주는 앱을 nginx 뒤에 둔다.
헤더가 어디까지 가는지를 재려는 것이라 이 경로에는 인증 프록시를 두지 않는다.
2. nginx 설정에 X-Auth-Request-* 를 지우는 줄을 넣지 않은 상태로 둔다.
3. 밖에서 X-Auth-Request-Roles 를 직접 붙여 인증 없이 보내고, 앱에 도착한 값을 확인한다.
4. 같은 위조 헤더로 토큰을 검증하는 경로도 함께 찌른다.
대조군이 없으면 「도착했다」를 「통했다」로 읽는다.
5. 클레임 반영은 따로 잰다. oauth2-proxy 를 앞에 둔 경로에서 로그인해 세션을 만든다.
6. IdP 에서 그 사용자의 클레임을 바꾸고, 같은 세션으로 요청을 반복하면서 도착한 값이 언제 바뀌는지 센다.
7. Redis 에서 그 세션을 지워 재인증시킨 뒤 값을 다시 확인한다.
8. 두 기계의 시계가 어긋나 있으면 먼저 보정한다.
보정하지 않으면 변경 뒤에 잰 요청이 변경 전으로 보인다.
## 본문
<!-- body:start -->
## 앱은 자기가 검증하지 않은 값을 읽어 인가한다
앞선 작업이 남긴 열린 질문 넷 가운데 Q4 는 「Forward-Auth 구조에서 Application Authorization 을 어디까지 Edge 에 둘 것인가」였다. Forward-Auth 는 요청을 앱으로 넘기기 전에 프록시가 인증 결과를 헤더로 붙여 주는 구조이고, 그래서 앱은 토큰을 직접 검증하는 대신 `X-Auth-Request-Roles` 같은 헤더를 읽어 누가 무엇을 할 수 있는지 정한다.
이 실험대에서 그 구조는 `nginx``oauth2-proxy` → 앱의 2홉이다. 요청이 호스트 nginx 로 들어와 oauth2-proxy 를 거쳐 앱에 닿으므로, 앱이 보는 헤더는 oauth2-proxy 가 붙인 것이라고 전제하게 된다. B-4 는 그 전제가 성립하는지 보려 했다.
**그래서 경로를 둘로 나눠 쟀다.** 헤더가 어디까지 가는지는 도착한 헤더를 그대로 되돌려주는 앱에서 쟀다. 그 경로에는 oauth2-proxy 가 없고 nginx 만 앞에 있어서, 도착한 값이 밖에서 온 것인지 프록시가 붙인 것인지 헷갈릴 여지가 없다. 클레임 변경이 언제 반영되는지는 oauth2-proxy 뒤에 세션을 만들어 놓고 따로 쟀다.
## 지우지 않은 이름은 그대로 지나간다
로그인도 하지 않고 위조한 `X-Auth-Request-User` · `X-Auth-Request-Email` · `X-Auth-Request-Roles` 를 붙여 요청을 보냈더니 셋 다 앱까지 그대로 도착했다. 예측한 것은 반대였다 — nginx 가 앞에 있으니 동명 헤더는 프록시 단계에서 덮어쓰일 것으로 봤다.
nginx 는 `proxy_set_header` 로 자기가 설정한 이름만 덮어쓴다. 설정하지 않은 이름은 클라이언트가 보낸 값을 건드리지 않고 뒤로 넘기므로, 그 헤더를 앱이 믿으려면 프록시가 그 이름을 먼저 빈 값으로 지워야 한다.
같은 이름의 헤더를 두 개 붙여 보냈을 때도 뒤엣것이 앞엣것을 밀어내지 않았다. 보낸 두 값이 모두 앱까지 그대로 도착했다 — 덮어쓰지도 합치지도 않는다.
```nginx label="앱이 읽는 헤더 이름을 프록시에서 먼저 비운다"
proxy_set_header X-Auth-Request-Roles ""
```
**이 줄은 적어만 놓고 넣어 보지 않았다.** 이 실험대의 nginx 설정에 이 줄이 들어간 적이 없고, 넣은 뒤에 위조 헤더가 사라지는지도 재지 않았다. 앞 문단의 「도착한다」는 관측이고 이 줄은 그 관측에서 따라 나오는 처방이라, 효과는 아직 재지 않았다.
![밖에서 들어온 위조 헤더가 프록시를 그대로 통과해 앱에 닿는 구성. 프록시가 그 이름을 설정할 때만 덮어쓴다.](../../../final/assets/b4-header-trust-boundary/b4-header-trust-boundary.svg)
그림에서 앱으로 들어가는 화살표는 하나뿐이다. 인증을 거친 요청이든 밖에서 헤더만 붙여 보낸 요청이든 같은 이름으로 도착하므로, 헤더를 읽어 인가하는 앱은 받은 요청만 보고 어느 쪽인지 가를 방법이 없다. 지우는 단계를 프록시에 넣어야 두 경로가 갈린다.
## 도착한 것과 인가를 뚫은 것은 다르다
같은 위조 헤더를 토큰을 검증하는 경로에도 보냈다. 결과가 갈렸다.
| 어디로 보냈나 | 돌아온 것 |
|---|---|
| 헤더를 되돌려주는 경로 | `200` · 여기는 원래 인증을 요구하지 않는다 |
| 토큰을 요구하는 두 경로 | 둘 다 `401` |
서명이 붙은 토큰을 요구하는 곳은 헤더 세 줄로 열리지 않았다. 위조 헤더가 앱까지 도착하는 것과 그 헤더로 인가가 뚫리는 것은 다른 사건인데, 이 실험은 앞의 것만 관측했다. 뒤의 것은 **앱이 그 헤더를 읽어 인가를 정할 때**만 따라온다.
값 안에 서명이 없으니 `request.getHeader("X-Auth-Request-User")` 는 그 값이 프록시에서 왔는지 클라이언트에서 왔는지 모른다. 그래서 헤더로 인가하는 앱은 두 경로를 가를 정보를 아예 못 받고, 토큰으로 인가하는 앱은 검증할 것이 있어서 갈린다.
## 값을 바꿔도 12회 요청·약 6.4초 동안 옛 값이 갔다
클레임 반영은 oauth2-proxy 뒤에서 쟀다. IdP 에서 값을 바꾼 뒤 0.5초 간격으로 12회를 보냈는데 12회 · 약 6.4초 동안 앱에는 옛 값이 갔고, Redis 세션을 지워 재인증시킨 뒤에야 새 값이 왔다.
oauth2-proxy 세션은 로그인 시점의 스냅샷이어서 `--cookie-refresh` 가 없으면 요청을 몇 번 보내든 쿠키 만료나 재인증까지 옛 값이 간다. 요청 횟수로는 줄일 수 없으므로, role 이나 tenant 변경이 즉시 반영돼야 하는 시스템에서는 인가 판단을 엣지에 두는 범위가 그만큼 좁아진다.
## 이 실험이 재지 않은 것
프록시에서 헤더를 먼저 지우는 수정을 넣어 보지 않았다. 넣으면 위조 헤더가 앱에 안 닿는다는 것은 nginx 의 동작에서 따라 나오는 예측이지 여기서 잰 값이 아니다.
`--cookie-refresh` 를 켠 구성에서는 재지 않았다. 클레임 반영 지연이 그 설정으로 얼마나 줄어드는지도 확인하지 않았다.
여기서 본 것은 호스트 nginx 와 oauth2-proxy 를 이렇게 엮은 한 구성이다. 다른 프록시나 다른 순서로 엮은 구조에서 같은 동작을 한다고 넓히지 않는다.
<!-- body:end -->
@@ -0,0 +1,137 @@
---
kind: CASE
slug: nobody-implemented-backchannel-logout
title: 백채널 로그아웃은 양쪽 다 없었다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-c층
assets:
- key: c2-backchannel-both-sides
file: ../../../final/assets/c2-backchannel-both-sides/c2-backchannel-both-sides.svg
evidence:
- ../../../final/evidence/raw/c2-backchannel-logout__01-current-state.txt
- ../../../final/evidence/raw/c2-backchannel-logout__03-logout-attempt.txt
- ../../../final/evidence/raw/c2-backchannel-logout__04-reachability.txt
---
# 백채널 로그아웃은 양쪽 다 없었다
로그아웃 통지를 보낼 backchannelLogoutUrl 도 받을 oidcLogout 엔드포인트도 없었다. 한 앱에서 로그아웃해도 다른 앱 세션이 끝나지 않았다. Keycloak 파드에서 앱 URL 로 요청하면 HTTP 200 이라 네트워크는 닿는다. 한쪽만 채워서는 전파되지 않는다.
## 관계
- **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다**
엣지가 만든 인증 결과가 앱까지 가는 경로를 재고, 이 실험은 그 인증을 끝내는 통지가 앱까지 가지 않는 경우를 쟀다.
- **믿기 전에 그 헤더를 먼저 지운다**
앱이 밖에서 온 값을 얼마나 믿을지 정하는 기준이고, 그 믿음을 거두는 통지가 여기서 끊겼다.
## 문제
백채널 로그아웃은 사용자가 한 앱에서 로그아웃하면 IdP 가 다른 앱에 서버 대 서버로 통지해 그쪽 세션도 끝내는 규격이다. 통지를 보내려면 IdP 가 부를 주소를 알아야 하고 앱에는 그 요청을 받아 세션을 지우는 엔드포인트가 있어야 한다.
C-1 에서 두 앱이 같은 realm 으로 SSO 되는 것을 확인하면서 로그아웃이 다른 앱으로 퍼지지 않는 것도 함께 관측했다. 설정이 빠진 것과 기능이 없는 것은 고치는 방법이 다른데, 로그아웃이 안 됐다는 결과만 보고는 어느 쪽인지 알 수 없다.
## 결론
두 클라이언트의 backchannelLogoutUrl : x
앱 소스의 수신 엔드포인트 (oidcLogout) : x
IdP 쪽만 채웠을 때 다른 앱 세션이 끝나는가 : x
Keycloak 파드에서 앱 URL 로 요청했을 때 : HTTP 200
네트워크는 닿는다. 빠진 것은 경로가 아니라 양쪽 끝의 구현이다.
통지를 보내는 쪽과 받는 쪽이 함께 빠져 있어서 한쪽만 채워서는 로그아웃이 전파되지 않는다. 확인 순서를 IdP 설정부터 시작해 거기서 멈췄다면 설정 하나를 채워 넣고 고쳤다고 판단했을 수 있다.
## 검증 환경
Keycloak realm : 두 앱이 같은 realm 을 쓴다
클라이언트 : 2개
앱 : 두 앱 모두 백채널 로그아웃 수신 엔드포인트 없음
실험대 : 베어메탈 test-server 한 대 위에 VM 두 대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af
실행일 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. 한 realm 에 클라이언트 둘을 만들고 앱 둘을 각각 붙인다.
2. 앱1 에 로그인한 뒤 앱2 를 열어 로그인 화면 없이 통과하는지 본다.
3. 앱1 에서 로그아웃하고 앱2 의 세션이 끝났는지 본다.
4. 두 클라이언트에 백채널 로그아웃 주소가 설정돼 있는지 확인한다.
kcadm get clients -r <realm> --fields clientId,attributes | grep -i backchannel
5. 앱 소스에 그 요청을 받는 엔드포인트가 있는지 확인한다.
6. IdP 쪽에만 주소를 채우고 다시 로그아웃해 앱2 의 세션을 확인한다.
7. Keycloak 파드에서 앱 URL 로 요청해 응답 코드를 확인한다.
## 본문
<!-- body:start -->
## SSO 는 됐고 로그아웃은 따라가지 않았다
C-1 에서 앱 둘을 같은 realm 에 붙였다. 앱1 에 로그인한 상태로 앱2 를 열면 로그인 화면 없이 통과한다. 같은 브라우저가 이미 Keycloak 에 로그인해 있으니 앱2 는 그 결과를 그대로 받는다.
앱1 에서 로그아웃한 뒤에도 앱2 는 계속 로그인 상태였다. 세션이 두 겹이기 때문인데, Keycloak 이 갖는 SSO 세션과 앱이 자기 사용자를 기억하는 애플리케이션 세션은 다른 것이라 한쪽을 끝낸다고 다른 쪽이 따라 끝나지 않는다. 앱2 의 세션을 끝내려면 누군가 앱2 에 그 사실을 알려야 한다.
그 통지를 맡는 규격이 백채널 로그아웃이다. 사용자가 한 앱에서 로그아웃하면 IdP 가 다른 앱에 서버 대 서버로 요청을 보내 그쪽 세션도 끝낸다. 브라우저를 거치지 않으므로 사용자가 그 앱 화면을 열고 있지 않아도 전파된다.
## 네 가지를 순서대로 확인했다
C-2 에서 물음을 넷으로 나눴다. 원인 후보가 셋이었고 판정하는 방법이 서로 달랐다. IdP 쪽 설정은 클라이언트 속성을 읽어야 알고, 앱 쪽 기능은 소스와 배포된 경로를 둘 다 봐야 알고, 네트워크 도달은 클러스터 안에서 직접 쳐야 안다. 셋 중 하나가 원인일 것으로 보고 시작했다.
| 무엇을 물었나 | 무엇이 나왔나 |
|---|---|
| 백채널 로그아웃이 설정되어 있었는가 | 아니다 — 두 클라이언트 모두 `backchannelLogoutUrl` 없음 |
| 앱에 그 엔드포인트가 있는가 | 아니다 — 소스에 `oidcLogout` 설정이 없다 |
| IdP 쪽만 설정하면 되는가 | 안 된다 — 앱 세션이 끝나지 않았다 |
| Keycloak 이 앱 URL 에 닿기는 하는가 | 닿는다 — `HTTP 200` |
첫 물음은 Keycloak 의 클라이언트 설정을 직접 읽어 답했다. 걸러 낸 출력이 비었다는 것만으로는 답이 되지 않는다. 빈 출력은 「없다」와 「명령이 안 먹었다」를 구별해 주지 않기 때문이다. 그래서 속성을 통째로 받아 훑었고, 두 클라이언트 모두 `frontchannelLogout` 은 보이는데 `backchannelLogoutUrl` 이 없었다. 다른 값이 보인다는 것이 명령은 먹었다는 증거다.
```bash label="클라이언트에 백채널 로그아웃 주소가 있는지 본다"
kcadm get clients -r <realm> --fields clientId,attributes | grep -i backchannel
# 그리고 앱 쪽에 수신 엔드포인트가 있는지 소스에서 확인한다
```
둘째 물음은 소스부터 봤다. `oidcLogout` 을 켜지 않으면 `/logout/connect/back-channel/{registrationId}` 경로가 생기지 않으므로, 소스에 없으면 경로도 없다. 다만 소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이라 배포된 쪽도 직접 쳤다. `/logout/connect/back-channel/keycloak` 도 `/backchannel-logout` 도 `/oauth2/sign_out` 도 `HTTP 302` 였다. 엔드포인트가 있었다면 요청 본문의 logout token 을 읽고 200 이나 400 을 돌려줬을 것이므로, 302 는 그런 핸들러가 없어 인증 요구로 떨어졌다는 뜻이다.
셋째 줄은 재기 전에 한 번 헛돌았다. 로그아웃을 걸었는데 그 시점 realm 의 세션 수가 0 이었고, 끊을 대상이 없으니 앱 세션이 그대로인 것은 당연한 결과였다. 명령은 정상적으로 실행됐고 출력도 그럴듯했고 결론도 원하던 방향이었는데 틀린 것은 전제뿐이라, 그 판을 버리고 브라우저로 로그인해 세션을 하나 만든 뒤 다시 걸었다.
IdP 쪽에 주소를 채우는 것도 한 번에 되지 않았다. 속성을 점 표기로 준 첫 명령이 종료 코드 1 로 끝났는데, 속성 이름 자체에 점이 들어 있어 관리 명령의 점 표기와 충돌하기 때문이다. JSON 으로 통째로 넘겨서 넣었다.
그렇게 세션이 살아 있는 판에서 주소만 채우고 다시 로그아웃했는데도 앱 세션은 개수도 이름도 그대로였다. 표의 셋째 줄이 이 실험의 답이고, 빠진 것이 설정 하나가 아니었다.
넷째 물음은 앞의 둘과 대조하려고 넣었다. 앱 세션이 안 지워지는 까닭이 요청이 못 닿아서라면 고쳐야 하는 것은 구현이 아니라 네트워크이고, 그때는 앞의 두 답을 알아도 소용이 없다. Keycloak 이미지에는 `curl` 도 `wget` 도 없어서 같은 네임스페이스에 임시 파드를 띄워 쳤고, 이름이 풀리고 `HTTP 200` 이 왔다.
Keycloak 로그도 훑었다. 로그 전체에서 `backchannel` 이 들어간 줄이 keycloak-0 도 keycloak-1 도 0 줄이었는데, 이것은 안 보냈다는 증거가 아니라 기본 로그 레벨에서는 안 보인다는 뜻이다. 0 줄을 근거로 「보내지 않았다」를 쓰면 나중에 디버그 로그를 켜서 보냈다는 것이 드러날 때 결론 전체가 함께 넘어간다. 확실한 것은 앱 세션이 안 지워졌다는 관측이고 그것은 직접 봤다.
## 설정이 빠진 것과 기능이 없는 것
![Keycloak 이 부를 주소와 앱이 받을 엔드포인트가 각각 비어 있어 로그아웃 통지가 어느 쪽에서도 성립하지 않는 구성.](../../../final/assets/c2-backchannel-both-sides/c2-backchannel-both-sides.svg)
그림의 통지 경로에는 화살표 둘이 차례로 놓여 있고 둘 다 조건이 붙어 있다. `backchannelLogoutUrl` 이 있어야 Keycloak 이 앱을 부르고, 앱에 수신 엔드포인트가 있어야 그 호출이 세션 삭제로 이어진다. 이 실험대에서는 앞 화살표도 뒤 화살표도 성립하지 않았다.
설정이 빠진 것과 기능이 없는 것은 고치는 방법이 다르다. 앞쪽은 Keycloak 클라이언트에 값을 채우는 일이고 뒤쪽은 앱에 코드를 넣는 일이다. 여기는 둘 다였으므로 IdP 설정만 확인하고 멈췄다면 값을 하나 채운 뒤 고쳤다고 판단했을 수 있다. 물음을 넷으로 나눠 순서대로 확인한 덕분에 그 판단을 하지 않았다.
뒤쪽 일의 크기도 두 앱이 같지 않다. 앱1 은 수신 엔드포인트를 켤 수 있지만, 앱2 가 쓰는 oauth2-proxy 는 백채널 로그아웃을 지원하지 않아 다른 방안을 찾아야 한다. 같은 realm 으로 SSO 를 묶어 두어도 로그아웃 전파는 앱마다 다르게 끝난다.
## 원인 확정까지가 이 실험의 범위다
양쪽을 다 구현해서 로그아웃이 실제로 전파되는지는 확인하지 않았다. 여기서 닫은 것은 「왜 안 되는가」까지다.
`HTTP 200` 도 도달만 확인한 값이다. 앱 URL 로 요청하면 응답이 온다는 것까지 봤고, 그 URL 이 로그아웃 통지를 처리하는지는 보지 않았다.
그 200 에는 이 실험대의 사정이 섞여 있다. tailnet 과 split DNS 로 묶여 있어 클러스터 안에서 공개 이름을 불러도 되돌아오는데, 앱이 사설망에 있고 IdP 가 밖에 있는 구성에서는 설정을 다 채워도 통지가 도달하지 못하고 그때는 로그도 안 남고 조용히 실패한다. 그리고 요청을 실제로 친 것은 Keycloak 파드가 아니라 같은 네임스페이스에 띄운 임시 파드다. NetworkPolicy 나 사이드카가 걸려 있으면 둘의 결과가 갈릴 수 있고, 이 실험대에는 그런 것이 없어서 대신 친 값을 그대로 썼다.
<!-- body:end -->
@@ -0,0 +1,165 @@
---
kind: CASE
slug: orphan-sessions-and-the-ttl-that-finds-them
title: 쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b7-b7a
assets:
- key: b7-cookie-session-tradeoff
file: ../../../final/assets/b7-cookie-session-tradeoff/b7-cookie-session-tradeoff.svg
evidence:
- ../../../final/evidence/raw/b7a-orphan-session__01-orphan-lifecycle.txt
- ../../../final/evidence/raw/b7-cookie-secret__01-deploy.txt
---
# 쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션
secret 을 바꾼 뒤 남은 고아 세션은 TTL 로 생성 시각을 역산해 골라 지웠고, 산 세션만 남았다. 프록시는 티켓을 못 풀어 Redis 키를 지우지 못했고, 값으로는 어느 것이 고아인지 구별할 수 없었다. 고아는 생성 후 정확히 1시간이면 사라졌다.
## 관계
- **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다**
같은 엣지 프록시를 쓰고, 그쪽은 세션이 로그인 시점의 스냅샷이라 클레임 변경이 늦게 반영되는 것을 쟀다.
- **믿기 전에 그 헤더를 먼저 지운다**
엣지가 만든 인증 결과를 앱이 어디까지 믿을지 정하는 기준이고, 여기서는 그 결과를 담은 세션을 지우는 쪽을 다룬다.
## 문제
oauth2-proxy 의 cookie secret 은 값을 하나만 받는다. 옛 secret 도 당분간 받아 주는 구간을 만들 수 없으므로 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다.
Redis 세션 저장소를 켜면 쿠키에는 티켓만 담기고 세션 본문은 Redis 에 있다. 그러면 secret 을 바꿨을 때 무엇이 막히는지가 달라진다. 프록시가 티켓을 못 풀고, 티켓 안에 세션 id 가 있으므로 어느 Redis 키를 지울지도 모른다.
프록시 로그에 남은 줄 : Error removing session: error decoding ticket to clear session
B-7 은 여기서 멈췄다. 지우지 못한 키가 언제까지 살아 있는지, 운영자가 지울 수 있는지, 어느 것이 고아인지 구별할 수 있는지는 재지 않았다.
## 결론
고아 세션이 사라지는가 : o — 생성 후 정확히 1시간
TTL 이 요청으로 갱신되는가 : x — 기동 로그의 refresh:disabled 와 같다
운영자가 지울 수 있는가 : o — redis-cli del 뒤에도 산 세션은 200
Redis 값으로 고아를 구별할 수 있는가 : x
이름 · 타입 · 크기 : 같다 (3510바이트)
값 : 암호화되어 있다
고아를 고르는 방법 : TTL 로 생성 시각을 역산한다
역산한 시각과 로그의 AuthSuccess 차이 : 1초
그 기준으로 골라 지운 결과 : 산 세션만 남았다
지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니다.
## 검증 환경
인증 프록시 : oauth2-proxy 두 replica
세션 저장소 : Redis
쿠키 secret : --cookie-secret 하나. 옛 값과 새 값이 함께 유효한 구간 없음
호스트 이름 : 인증서 SAN(Subject Alternative Name) 에 auth · app1 · app2 셋뿐이라 Grafana 의 app2 를 빌렸다
실험대 : 베어메탈 test-server 한 대 위에 VM 두 대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af
실행일 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. oauth2-proxy 를 두 replica 로 띄우고 Redis 세션 저장소를 켠다.
2. 로그인한 뒤 Redis 에 세션 키가 생겼는지 확인한다.
kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
3. --cookie-secret 을 새 값으로 바꾸고 배포한다.
4. 브라우저로 다시 접근한다. 옛 쿠키가 검증에 실패하고 새 세션이 생긴다.
5. Redis 키를 다시 조회해 각 키의 TTL 을 읽는다.
6. 일정 간격으로 TTL 을 다시 읽어 요청이 TTL 을 늘리는지 본다.
7. 생성시각 = 지금 (cookie-expire TTL) 로 각 키의 생성 시각을 구하고 secret 을 바꾼 시각과 견준다.
8. 회전 시각보다 이른 키를 지우고, 산 세션이 그대로 응답을 받는지 확인한다.
## 본문
<!-- body:start -->
## 서버에 상태가 없으니 공유할 것도 없다
B층 실험의 주제는 BFF(Backend For Frontend) 가 서버에 들고 있던 세션과 토큰을 Redis 와 PostgreSQL 로 빼는 것이었다. oauth2-proxy 는 정반대다. 세션 전체가 쿠키에 있고 replica 는 같은 k8s Secret 을 읽을 뿐이므로, 인스턴스 사이에 맞출 상태가 없어 콜백이 다른 replica 로 가도 문제가 없다.
대신 `--cookie-secret` 이 값을 하나만 받는다. 「옛 secret 도 당분간 받아 준다」를 표현할 방법이 없으니 겹침 구간을 만들 수 없고, secret 을 교체하는 순간 발급돼 있던 쿠키가 한꺼번에 무효가 된다.
쿠키가 한꺼번에 무효가 되는 것은 사용자 쪽에서 잘 보이지 않는다. Keycloak 의 SSO 세션이 살아 있으면 애플리케이션 세션이 죽어도 로그인 화면 없이 조용히 다시 인증되기 때문이다. 세션이 두 겹이라 앱 쪽 한 겹만 끊긴다.
## 티켓을 못 풀면 어느 키를 지울지 모른다
Redis 세션 저장소를 켜면 쿠키에 담기는 것이 세션 전체에서 티켓으로 바뀐다.
```text label="쿠키에 담기는 티켓의 구조"
티켓 = <세션 ID>.<암호화 키>
│ └─ 값을 복호화할 키
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
```
티켓 전체가 `--cookie-secret` 으로 암호화되어 있다. secret 을 바꾸면 프록시는 티켓을 열지 못하고, 세션 ID 를 읽지 못하니 Redis 키 이름도 만들지 못한다. 그래서 로그에 이 줄이 남는다.
```text label="secret 을 바꾼 뒤 프록시가 남긴 로그"
Error removing session: error decoding ticket to clear session
```
![세션이 쿠키에 담기고 replica 는 같은 Secret 만 읽는 구성. Redis 저장소를 켜면 쿠키에 티켓만 남고 서버에 세션이 생긴다.](../../../final/assets/b7-cookie-session-tradeoff/b7-cookie-session-tradeoff.svg)
그림에서 Redis 로 들어가는 화살표는 쿠키의 티켓에서만 나온다. 키 이름을 만드는 경로가 그 하나뿐이라, 티켓이 안 열리면 Redis 쪽에서 그 세션을 가리킬 방법이 없어진다. 이렇게 프록시가 지우지 못한 채 Redis 에 남은 세션이 고아 세션이다.
## Redis 값으로는 고아를 구별할 수 없었다
B-7 이 남긴 말은 「지우지 못했다」였다. 그 문장을 그대로 믿으면 고아는 어쩔 수 없는 것이 되는데, 못 지우는 주체가 프록시인지 Redis 인지는 거기서 갈리지 않았다. 그 갈래를 포함해 B-7a 가 셋을 이어서 쟀다.
| 물음 | 잰 결과 |
|---|---|
| 고아는 정말 사라지는가 | 사라진다. 생성 후 정확히 1시간. TTL 이 갱신되지 않는다 |
| 운영자가 지울 수 있는가 | 있다. `redis-cli del` 후에도 산 세션은 `200` |
| 어느 것이 고아인지 아는가 | Redis 값으로는 모른다. 이름·타입·크기(3510바이트)가 같고 값은 암호화 |
| 그럼 어떻게 고르는가 | TTL 로 생성 시각을 역산한다 |
첫 줄이 먼저 정해져야 나머지가 의미를 갖는다. 고아가 영영 쌓이는 것이라면 정리 규칙을 만드는 것과 별개로 저장소가 계속 커지기 때문이다. TTL 은 요청을 보내도 늘지 않았고, 이 구성의 기동 로그에 찍힌 `refresh:disabled` 와 맞는다.
고아가 생기는 시점도 secret 을 바꾸는 순간이 아니다. 회전 직후 Redis 의 키 수는 그대로였고, 브라우저가 옛 쿠키를 들고 다시 와서 프록시가 그것을 못 푼 다음에야 새 세션이 하나 더 생기면서 앞의 것이 고아가 됐다. 사람이 접근하지 않으면 고아도 안 생긴다.
회전을 한 번 더 걸었더니 1차 회전에서 살아남았던 세션이 이번에는 고아가 됐다. 회전 한 번이 그 시점에 로그인해 있던 사용자 수만큼 고아를 만드는 셈이고, 그 고아들도 생성 후 1시간이면 사라진다.
셋째 줄이 실제로 막혔던 곳이다. 키 이름은 접두사가 같고 뒤는 불투명한 값이며, 타입도 크기도 같고 값은 암호화되어 있다. 값의 md5 를 떠 봐도 둘이 다르다는 것만 알 뿐, 어느 쪽이 산 세션인지는 그 차이에서 나오지 않는다. 두 키를 나란히 놓고 보면 다른 것은 TTL 하나뿐이었다.
```bash label="Redis 에 남은 세션 키를 훑는다"
kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
```
## TTL 이 갱신되지 않으니 생성 시각을 되돌릴 수 있다
TTL 이 요청으로 갱신되지 않는다는 것이 구별의 근거가 됐다. 30초 간격으로 세 번 읽었더니 산 세션은 3557 · 3526 · 3494 였고 고아는 3479 · 3448 · 3417 이었다. 1초에 1초씩 줄기만 한다.
그래서 이 방법은 기동 로그의 `refresh:disabled` 한 단어에 통째로 매달려 있다. 재기 전에 그것부터 읽었다. 갱신이 없으면 남은 TTL 은 만료 설정에서 흘러간 시간을 뺀 값이므로, 거꾸로 계산하면 그 세션이 언제 만들어졌는지 나온다.
```text label="TTL 에서 생성 시각을 되돌린다"
생성시각 = 지금 (cookie-expire TTL)
```
이 값이 secret 을 바꾼 시각보다 이르면 그 키는 고아다. 회전 뒤에 생긴 세션은 새 secret 으로 만들어졌으므로 유효하다.
시각은 전부 UTC(협정 세계시)로 다뤘다. 이 방법의 결론이 시각 계산이라 한국 시간과 한 번만 섞여도 9시간이 통째로 틀어진다.
역산이 맞는지는 로그와 견줘 확인했다. 계산한 `11:30:26` 과 로그의 `AuthSuccess 11:30:27` 이 1초 차였다. 그 기준으로 실제로 골라 지웠고 산 세션만 남았다. 지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니었다 — 프록시는 티켓을 못 풀어 키 이름을 만들지 못하지만 운영자는 키 목록을 직접 본다.
## 이 방법이 성립하지 않는 구성
이 정리는 남의 세션을 실제로 지우는 일이다. 기준을 잘못 잡아 산 세션을 지우면 그 사람은 다시 인증해야 하는데, Keycloak 의 SSO 세션이 살아 있으면 로그인 화면 없이 조용히 지나가므로 잘못 지웠다는 것이 밖에서 보이지 않는다.
`--cookie-refresh` 를 켜면 이 역산이 무너진다. TTL 이 요청마다 갱신되면 남은 TTL 이 생성 시각의 함수가 아니게 되고, 그러면 회전 시각과 견줄 값이 없다. 그 구성에서 고아를 어떻게 고를지는 재지 않았다. 회전 뒤 `FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 정직하다.
고아가 사라지기까지 잰 1시간도 이 구성에서 나온 값이다. 쿠키 만료를 다르게 잡은 구성에서는 다시 재지 않았다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: REFERENCE
slug: clear-the-header-before-you-trust-it
title: 믿기 전에 그 헤더를 먼저 지운다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b4
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답
evidence:
- ../../../final/evidence/raw/b4-edge-authorization__01-header-handling.txt
- ../../../final/evidence/raw/followup__03-b4-role-propagation.txt
---
# 믿기 전에 그 헤더를 먼저 지운다
엣지가 넣어 준 신원 헤더는 업스트림으로 넘기기 전에 그 프록시에서 직접 지운다. nginx 는 proxy_set_header 로 자기가 설정한 이름만 덮어써서, 밖에서 붙인 같은 이름의 헤더가 permitAll 경로의 앱까지 그대로 도착했다. 지우기의 효과는 이 실험대가 재지 않았다.
## 관계
- **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다**
이 기준이 나온 실험이다. 위조 헤더가 어디까지 도착했고 어디서 401 이 됐는지가 거기 있다.
- **쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션**
엣지가 로그인 때 만든 세션을 다루는 쪽이다. IdP 에서 바꾼 값이 늦게 온 것도 그 세션을 지워 재인증시키기 전까지였다.
## 목적
엣지가 인증을 대신하고 그 결과를 헤더로 업스트림에 넘기는 구성에서, 업스트림이 받은 헤더의 출처를 구별하지 못한 채 인가에 쓰는 것을 막는다. 위조 경로와 정상 경로가 같은 헤더 이름을 쓰므로, 지우는 단계가 없으면 업스트림에는 같은 값으로 보인다.
프록시가 동명 헤더를 알아서 덮어쓸 것이라는 전제부터 틀렸다. 이 실험대는 nginx 한 겹에서 그 전제가 깨지는 것을 봤고, 나머지 프록시는 확인하지 못했다.
## 규칙
### 1. 엣지가 넣는 신원 헤더는 업스트림으로 넘기기 전에 지운다
프록시 설정에서 그 헤더 이름을 빈 값으로 덮어쓰는 줄을 먼저 둔다. 원본 가이드가 적은 형태는 proxy_set_header X-Auth-Request-Roles "" 다.
이 실험대는 그 수정을 적용한 적이 없다. 적용한 뒤 위조가 막히는지도 재지 않았고, 원본 가이드가 「아래는 미검증이며, 적용하려면 랩 호스트에서 사람이 직접 친다」로 못박고 있다. 검증된 완화책이 아니라 아직 확인하지 않은 처방으로 읽는다.
### 2. 그 프록시가 무엇을 덮어쓰는지 설정에서 읽고, 밖에서 한 번 보내 본다
랩 호스트의 nginx 설정에 있던 것은 Host · X-Forwarded-Host · X-Forwarded-Proto · X-Forwarded-Port · X-Forwarded-For · X-Real-IP 여섯 줄이고 X-Auth-Request-* 는 없었다. 설정에 있는 이름은 덮어쓰고, 없는 이름은 클라이언트가 보낸 값이 그대로 업스트림까지 간다.
설정을 읽는 것만으로는 끝나지 않는다. 밖에서 같은 이름을 붙여 보내 업스트림에 무엇이 도착하는지 보고, 그 전에 아무것도 안 붙인 요청을 한 번 찍어 둔다. 그 대조가 없으면 원래 있던 값과 내가 넣은 값이 구별되지 않는다.
### 3. 헤더가 도착한 것과 인가가 뚫린 것을 같은 사건으로 세지 않는다
아무 인증 없이 보낸 신원 헤더 셋은 검증 없이 그대로 도착했다. 같은 헤더로 토큰을 요구하는 경로를 찔렀을 때는 401 이 왔다.
/api/echo : HTTP 200 (permitAll)
/api/me : HTTP 401
/api/protected : HTTP 401
헤더가 도착했다는 사실만으로 인가가 뚫렸다고 세지 않는다. 위험은 헤더만 읽어 인가하는 앱이 그 뒤에 있을 때 생긴다.
### 4. IdP 에서 클레임을 바꿔도 이미 로그인한 요청에는 옛 값이 간다
IdP 에서 값을 바꾼 뒤 12회 · 약 6.4초 동안 옛 값이 갔고, Redis 세션을 지워 재인증시킨 뒤에야 새 값이 왔다. 요청 횟수로는 반영되지 않는다. 바뀐 값이 곧바로 적용돼야 하면 세션을 지워 재인증시키는 경로를 미리 정해 둔다.
## 적용 조건
- 엣지가 인증을 대신하고 그 결과를 헤더로 업스트림에 넘기는 구성
- 확인한 프록시 : host nginx 한 겹
- 같은 체인의 k3s Traefik : 헤더 처리 미측정
- 다른 프록시 : 이 결과를 옮기기 전에 그 프록시에서 같은 확인을 다시 한다
## 예외
- 업스트림이 헤더가 아니라 서명된 토큰을 검증하면 이 지우기가 필요 없다. 토큰을 요구하는 두 경로는 같은 위조 헤더에 401 로 답했다.
- 위조를 잰 경로는 permitAll 인 echo 앱이라 oauth2-proxy 를 거치지 않았다. nginx 와 oauth2-proxy 를 다 지난 요청에 무엇이 도착하는지는 이 측정에 없다.
- 쿠키가 만료되면 옛 클레임이 씻기는지는 재지 않았다. 만료를 기다려 본 적이 없고, --cookie-refresh 가 준다는 최대 지연도 설정의 정의일 뿐 이 실험대에서 잰 값이 아니다.
## 예시
- 밖에서 아무 인증 없이 붙인 x-auth-request-user · x-auth-request-email · x-auth-request-roles 가 그대로 앱에 도착했다.
- 같은 이름의 헤더를 두 개 보내면 덮어쓰지도 합치지도 않고 admin 과 editor 가 둘 다 도착했다.
- IdP 에서 이메일 클레임을 바꾼 뒤 12회를 더 보냈는데 전부 옛 값이었고, 세션을 지운 뒤 보낸 3회는 새 값이었다.
@@ -0,0 +1,781 @@
---
id: 9bb0b051-c158-497f-a744-b24769c93783
kind: SETUP
slug: reproduce-b4-forged-identity-headers
title: 신원 헤더를 위조해 보내고 어디까지 도착하는지 본다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/9bb0b051-c158-497f-a744-b24769c93783/edit"
pinnedVersions:
- name: curl
version: 8.5.0
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-4
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 신원 헤더를 위조해 보내고 어디까지 도착하는지 본다
신원 헤더를 위조해 보내고 어디까지 도착하는지, 같은 헤더를 토큰이 필요한 경로에 보내면 어떻게 되는지를 대조군과 함께 재는 절차다. 앞 세 절은 클러스터 상태를 안 바꾸고, 4절부터 Grafana 의 Ingress 를 빌린다.
## 관계
- **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다**
이 절차가 재는 동작의 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
- **믿기 전에 그 헤더를 먼저 지운다**
이 절차가 재고 나온 기준이다. 처방을 이 실험대가 적용하지 않았다는 것도 거기 같이 적혀 있다.
- **두 시계에서 온 값을 빼지 않는다**
5절의 반영 지연은 두 기계의 로그를 나란히 놓고 읽는다. 보정을 안 하면 결론이 뒤집힌다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
같은 oauth2-proxy 와 같은 Redis 키를 쓴다. 이어서 하려면 Ingress 를 붙여 둔 채 넘어간다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기계가 셋이다. 위조 요청을 보내는 `curl` 은 어디서 쳐도 되고 밖에서 치는 편이 공격자 관점에 가깝다. `kubectl``[kc-lab-1]` 에서 치고 `sudo` 를 붙이지 않는다. **랩 호스트(`test-server`)로 넘어가는 것은 nginx 설정을 만지는 단계 하나뿐이고**, 그 블록만 `[test-server]` 라벨이 붙어 있다. 5절의 클레임 반영은 브라우저 콘솔에서 잰다 — oauth2-proxy 세션 쿠키가 `HttpOnly``curl` 로 로그인 상태를 재현할 수 없다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `header-lab`(echo) · `keycloak-lab`(BFF·oauth2-proxy) · `observability`(Grafana) |
| 재는 경로 | `https://app1.hyeonworks.com/api/echo` — 도착한 헤더를 그대로 되돌려주는 앱 |
| 대조 경로 | 같은 호스트의 `/api/me` · `/api/protected` — 토큰을 요구한다 |
| 위조 수단 | `curl -H` 세 줄. 쿠키도 토큰도 없다 |
| 빌리는 이름 | `app2.hyeonworks.com` — 4절에서 Grafana 에게 잠시 빌린다 |
| 전 구간 | 약 30분. 1~3 절만 하고 멈춰도 결론 대부분이 나온다 |
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `grep -o` 로 뽑는다 |
**무엇을 재는 경로인지 먼저 못박는다.** 위조 헤더를 보내는 `https://app1.hyeonworks.com/api/echo``header-lab` 네임스페이스의 echo 앱으로 가고, 그 경로는 `permitAll` 이라 oauth2-proxy 를 거치지 않는다. 이 절차가 재는 것은 엣지가 인증을 끝낸 뒤의 인가가 아니라, 헤더를 받아 쓰는 업스트림이 그 값을 검증하는가다. 같은 위조 헤더를 토큰이 필요한 경로에 보내면 거기서 막히고, 그 대조를 주입 검증 절이 같이 친다.
## 이 실험이 가르는 것
엣지(oauth2-proxy·nginx)가 인증을 끝내고 신원을 헤더로 뒤에 넘기는 구조가 있다. `X-Auth-Request-User`, `X-Auth-Request-Roles` 같은 것들이고, 뒤쪽 애플리케이션은 그 헤더를 읽어 사용자를 안다. 그러면 그 헤더는 무엇을 보증하는가. Q4 는 확인한 사실로 이렇게 적어 두었다.
> *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"*
넷을 따로 잰다.
```text
① 여러 값을 어떻게 넣는가 쉼표? 헤더를 여러 개? → 구별할 수 있나
② 커지면 어떻게 되는가 잘리나? 거부되나?
③ IdP 에서 바꾸면 언제 반영되나
④ 위조하면 통하는가 ★ 여기가 권한의 문제다
```
헤더가 누구인지만 말하면 위조는 인증 우회가 된다. 헤더가 무엇을 할 수 있는지(role)까지 말하면 위조는 권한 상승이 된다. 로그인한 일반 사용자가 자기 요청에 `X-Auth-Request-Roles: admin` 을 한 줄 더 붙이는 것으로 끝난다. 그래서 이 구조는 세 곳이 동시에 성립해야만 안전하다고 가이드가 적는다.
```text
① 외부 → upstream 직접 경로 차단 (NetworkPolicy)
② edge 에서 동명 헤더 덮어쓰기 (proxy_set_header)
③ upstream 에서 내부 credential 검증 (공통 경계)
```
하나라도 빠지면 나머지 둘이 무의미하다. 이 절차는 ②가 빠져 있다는 것을 재고, 그 결과로 ④가 성립한다는 것을 재고, ③이 한 곳에만 있다는 것을 확인한다.
## 전제와 되돌리기
- `03-nginx` · `04-tls` · `05-keycloak` 이 끝나 있다.
- B-0 이 끝나 BFF 와 Redis 가 떠 있다.
- **`app1.hyeonworks.com` 이 경로에 따라 둘로 갈린다.** `/` 는 BFF, `/api``header-lab` 네임스페이스의 echo 앱이다. 이 절차는 `/api/echo` 만 쓴다.
- 4절부터는 `app2.hyeonworks.com` 을 Grafana 에서 잠시 빌린다. 인증서가 `auth` · `app1` · `app2` 만 덮으므로 네 번째 이름을 만들 수 없다.
- 4절은 브라우저가 필요하다. oauth2-proxy 세션 쿠키가 `HttpOnly``curl` 로 로그인 상태를 재현할 수 없다.
`HttpOnly` 는 짐작이 아니라 기동 로그에 적혀 있다.
```bash label="[kc-lab-1] 쿠키 속성을 기동 로그에서 읽는다"
kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1
```
실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
httponly:true expiry:1h0m0s ... refresh:disabled
```
그래서 원래 실행도 Playwright 로 연 브라우저를 썼다.
앞부분은 안전하고 뒷부분이 상태를 바꾼다.
| 절 | 무엇을 하나 | 되돌릴 것 |
|---|---|---|
| 1~3 | **요청만 보낸다.** 클러스터 상태가 안 바뀐다 | 없음 |
| 4 | Grafana 에서 app2 를 빌리고 **IdP 의 사용자 속성을 바꾼다** | Ingress · email 값 · 세션 |
| 5 | **nginx 설정을 바꾼다** (호스트) | 설정 파일 |
되돌리기는 셋이고 셋 다 먼저 읽어 둔다.
```bash label="[kc-lab-1] 중간에 그만둘 때 ① Ingress 를 돌려준다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f /tmp/grafana-ingress-backup.yaml
```
`$USER_ID` 는 주입 5절 ③ 에서 잡는 값이라 여기를 먼저 읽는 지금은 비어 있다. 실제로 칠 일이 생기는 것은 그 절을 친 뒤이고, 1~3 절만 하고 그만두는 사람은 email 을 바꾼 적이 없으니 이 줄 자체가 필요 없다.
```bash label="[kc-lab-1] 중간에 그만둘 때 ② IdP 의 email 을 되돌린다 — $USER_ID 를 잡은 뒤에 친다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update users/$USER_ID -r keycloak-patterns -s email=labuser@example.com
```
`keycloak-lab.b4-backup` 은 관찰 절 끝의 처방 단계에서 뜨는 파일이라 그 단계를 안 쳤으면 없다. 셋을 미리 읽어 두라는 것은 읽으라는 뜻이고 지금 치라는 뜻이 아니다 — 없는 상태로 치면 `cp: cannot stat` 로 끝난다(망가지지는 않는다).
```bash label="[test-server] 중간에 그만둘 때 ③ nginx 설정을 되돌린다 — 백업을 뜬 뒤에만"
sudo cp /etc/nginx/sites-available/keycloak-lab.b4-backup /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx
```
## 주입 전에 같은 명령으로 먼저 본다
시험군만 재는 측정은 측정이 아니다. 위조 헤더가 도착했다고 말하려면 아무것도 안 붙였을 때 무엇이 도착하는지를 먼저 봐야 한다. 넓은 것부터 좁혀 간다.
```text
경로 확인 → echo 응답 통째로 보기 → 대조군(아무것도 안 붙임) → nginx 가 지금 뭘 설정하나
```
### 1. app1 이 경로에 따라 어디로 가는가
**무엇을 보는가** — 같은 호스트를 잡고 있는 Ingress 목록.
```bash label="[kc-lab-1] ① Ingress 를 전부 본다"
kubectl get ingress -A
```
**어디를 보나** — 모양은 이렇다(observed).
```text
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 label="[kc-lab-1] ② echo 의 경로 규칙을 본다"
kubectl -n header-lab describe ingress echo | grep -A5 Rules
```
```text
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 앱으로 간다. 이 확인을 건너뛰면 뒤에 나오는 `200` 을 「BFF 가 위조 헤더를 받아 줬다」로 읽게 된다.
`kubectl get endpoints` 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜬다. 위처럼 `describe ingress` · `describe svc` 를 보거나 `get endpointslice -l kubernetes.io/service-name=echo` 를 본다.
### 2. echo 응답을 통째로 본다
**무엇을 보는가** — 어떤 키가 있는지. 무엇으로 거를지는 그 뒤에 정한다.
```bash label="[밖에서] 응답을 통째로 본다"
curl -s https://app1.hyeonworks.com/api/echo
```
**어디를 보나** — 한 줄 JSON 이 통째로 나온다(모양은 observed).
```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 가 같은 이름의 헤더를 여러 번 허용하기 때문이고, 동명 헤더를 두 개 보냈을 때 무엇이 도착했는지도 이 배열이 말해 준다. `x-forwarded-proto` 가 `https` 인 것은 nginx 가 `proxy_set_header` 로 설정한 헤더라서다. `scheme` · `secure` · `serverName` 은 Keycloak 이 `iss` 클레임과 리다이렉트를 만들 때 쓰는 값들이다.
걸러 볼 때는 `grep -o` 를 쓴다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
```bash label="[밖에서] 한 헤더만 뽑아 본다"
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto":\[[^]]*\]'
```
```text
"x-forwarded-proto":["https"]
```
**`tr ',' '\n' | grep` 은 여기서 쓰면 안 된다.** 값 배열이 `["admin","editor"]` 처럼 쉼표를 품고 있어서 배열이 두 줄로 잘린다. 첫 줄만 보고 「하나만 도착했다」로 읽게 되는데, 이 절차에서 가장 조심할 오독이다. `grep -o '…\[[^]]*\]'` 는 대괄호 안을 통째로 뽑는다.
### 3. 대조군 — 아무것도 안 붙였을 때 무엇이 도착하나
**무엇을 보는가** — `x-auth-request-*` 칸이 비어 있는지. 가이드가 이 줄도 미검증으로 표시했다(unknown).
```bash label="[밖에서] 대조군 — 아무것도 안 붙이고 찾아본다"
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request[^]]*\]'
```
**어디를 보나** — 아무것도 안 나와야 한다.
**이 값이 뜻하는 것** — `x-auth-request-*` 는 엣지가 붙이는 헤더인데 `app1` 앞에는 oauth2-proxy 가 없으므로 지금은 없다. 이 칸이 비어 있는 것이 대조군이다. 주입 뒤 여기에 값이 나타나면 그건 내가 보낸 것이 도착한 것이고, 이 확인을 건너뛰면 원래 있던 것과 내가 넣은 것이 구별되지 않는다.
### 4. nginx 가 지금 무엇을 설정하는가
**무엇을 보는가** — `proxy_set_header` 목록. 랩 호스트에서 친다.
```bash label="[test-server] nginx 가 설정하는 헤더를 본다"
sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab
```
**어디를 보나** — `03-nginx` 가 세운 설정 그대로다(모양은 observed).
```text
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-*` 가 목록에 없고, 그것이 주입 결과를 전부 설명한다.
```nginx
proxy_set_header X-Forwarded-Proto https; # 설정한 것 → 덮어쓴다
# X-Auth-Request-Roles 설정 없음 # 안 한 것 → 그대로 흘려보낸다
```
nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다. 설정하지 않은 헤더는 손대지 않고 통과시킨다. 「nginx 가 덮어쓴다」는 명제는 조건부이고, 그 조건이 빠지면 틀린 문장이 된다.
**`sudo` 가 아무 결과도 안 주면 실패한 것이다.** 랩 호스트의 sudo 는 비밀번호를 요구한다(`sudo -n -l` → `sudo: a password is required`). 빈 출력을 「설정이 없다」로 읽지 말고 비밀번호를 넣어 다시 친다.
## 주입
주입은 둘이다. 첫째는 요청에 헤더를 붙여 보내는 것이고, 둘째는 IdP 에서 클레임을 바꾸는 것이다. 첫째는 클러스터 상태를 바꾸지 않아 되돌릴 것이 없다. 아무것도 설치하지 않고 아무 권한도 없이 `curl` 한 줄로 여기까지 간다.
### 1. 동명 헤더 두 개를 보낸다
**목적** — 같은 이름의 헤더 둘이 도착 시점에 어떻게 보이는지 만든다.
① 같은 헤더를 값만 달리해 두 번 붙인다. 가이드가 미검증으로 표시했다(unknown) — 원래 실행은 스크립트가 응답을 정리했고 아래는 같은 값을 `grep` 으로 뽑는 형태다.
```bash label="[밖에서] 동명 헤더 두 개를 보낸다"
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":\[[^]]*\]'
```
**예상 결과** — 대괄호 안에 값이 둘 들어온다. 판정은 관찰 절에서 한다.
**왜 필요한가** — 덮어쓰는지, 합치는지, 통과시키는지 셋 중 어느 것인지가 여기서 갈린다.
**문제가 생기면** — 하나만 온 것처럼 보이면 `tr ',' '\n'` 으로 자르지 않았는지 본다.
### 2. 값 안의 쉼표를 보낸다
**목적** — 구분자로 쓰는 쉼표와 값에 들어간 쉼표를 도착 시점에 구별할 수 있는지 만든다.
① 쉼표로 구분한 값 하나와 값 안에 쉼표가 든 값 하나를 각각 보낸다.
```bash label="[밖에서] 쉼표 구분과 값 안 쉼표를 각각 보낸다"
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":\[[^]]*\]'
```
**예상 결과** — 두 줄이 같은 모양으로 온다.
**왜 필요한가** — role 이름에 쉼표가 들어갈 수 있다면 쉼표로 자르는 방식이 성립하지 않는다.
**문제가 생기면** — 대괄호째 뽑았는지 다시 본다.
### 3. 헤더를 키운다
**목적** — 크기 상한에서 무슨 일이 나는지 만든다.
① 먼저 한 번은 읽는 형태로 본다. 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다. 두 줄 다 미검증이다(unknown) — 원래 실행은 값을 파이썬으로 만들었다.
```bash label="[밖에서] ① 8000자짜리 값을 만들어 응답을 읽는다"
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
```
② 여러 크기를 비교할 때는 코드만 뽑는 형태로 바꾼다.
```bash label="[밖에서] ② 다섯 크기의 상태 코드만 뽑는다"
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
```
**예상 결과** — 다섯 줄이 나오고 뒤 셋이 앞 둘과 다르다. 값은 관찰 절에 있다.
**왜 필요한가** — 읽는 형태를 한 번 보지 않으면 `400` 이 무엇을 돌려준 `400` 인지 모른다. 8000 에서 오는 것은 JSON 이 아니라 HTML 오류 페이지다.
**문제가 생기면** — `000` 이 나와도 명령이 잘못된 것이 아니다. 그건 측정 결과다.
### 4. 신원 자체를 위조한다
**목적** — 로그인 없이 신원 헤더 세 줄만 보낸 상태를 만든다.
① 쿠키도 토큰도 없이 헤더 세 줄만 붙인다.
```bash label="[밖에서] 로그인하지 않고 신원 헤더 세 줄만 보낸다"
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
```
**예상 결과** — echo 앱이 응답을 돌려준다. 무엇이 돌아왔는지는 주입 검증에서 읽는다.
**왜 필요한가** — 여기까지가 요청만으로 되는 부분이다. 설치한 것도 받은 권한도 없다.
**문제가 생기면** — 응답이 안 오면 1절의 경로 확인으로 돌아간다.
### 5. Ingress 를 빌리고 IdP 의 값을 바꾼다
**목적** — 클레임 변경이 언제 반영되는지 재려고 엣지 세션을 실제로 만든다. 그러려면 oauth2-proxy 가 필요하고, 그것이 app2 를 쓴다.
① 백업이 먼저다. 파일이 생겼는지 줄 수로 확인한 뒤에 원본을 지운다.
```bash label="[kc-lab-1] ① Grafana Ingress 를 백업하고 프록시를 올린다"
kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
wc -l /tmp/grafana-ingress-backup.yaml
kubectl -n observability delete ingress grafana
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s
```
② 브라우저에서 `https://app2.hyeonworks.com/` 를 열고 `labuser` / `labpass` 로 로그인한다.
**③ 을 치기 전에 변경 전 값을 먼저 잰다.** 관찰 ⑤ 의 첫 JS 블록(3회 반복)을 로그인된 app2 탭의 콘솔에서 지금 돌리고 세 줄을 적어 둔다. ③ 을 친 뒤에 재면 **세션이 스냅샷이라 변경 뒤에도 옛 값이 나오므로 두 측정이 화면에서 똑같아 보인다.** 대조군이 무너졌다는 것을 알아챌 단서가 없어서, 그대로 읽으면 「12회를 보내도 안 바뀐다」를 재지 않고 그냥 적게 된다.
③ IdP 의 email 을 바꾸고 변경 시각을 UTC 로 남긴다.
```bash label="[kc-lab-1] ③ IdP 의 email 을 바꾸고 시각을 남긴다"
USER_ID=$(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=$USER_ID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update users/$USER_ID -r keycloak-patterns -s email=CHANGED-labuser@example.com
date -u '+%Y-%m-%dT%H:%M:%SZ 변경'
```
**예상 결과** — ①의 첫 줄은 이렇다(observed, `01-deploy.txt`).
```text
grafana ingress 삭제
```
**왜 필요한가** — `wc -l` 은 백업 파일이 비어 있는데 삭제부터 하는 사고를 막는다. 0 줄이면 거기서 멈추고 원본을 지우지 않는다. 파일이 생겼는지 확인하지 않고 원본을 지우는 것이 이런 작업에서 가장 흔한 사고다.
**문제가 생기면** — `rollout status` 가 타임아웃이면 Ingress 를 먼저 돌려놓고 다시 시작한다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 본다.
**첫째 주입은 대조군 칸에 값이 나타났는가로 확인한다.** 실측은 이렇다(observed, `01-header-handling.txt`).
```text
=== 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
→ 그대로 도착. 검증 없음.
```
주입 전에 비어 있던 칸에 값이 들어와 있고, `remoteAddr` 이 내 주소다 — 숨지도 않았다. 증거 파일의 `['admin', 'editor']` 같은 표기는 스크립트가 정리한 것이고, `curl` 로 직접 보면 같은 값이 JSON 배열 `["admin","editor"]` 로 온다.
**여기서 「도착했다」를 「통했다」로 옮기면 틀린다.** 도착해도 아무도 안 읽으면 무해하다. 읽는 쪽이 검증을 하는지를 같은 헤더로 확인한다.
```bash label="[밖에서] 같은 위조 헤더를 세 경로에 보낸다"
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
```
실측은 이렇다(observed, 같은 파일).
```text
대조 — JWT 를 요구하는 경로:
/api/echo HTTP 200 (permitAll)
/api/me HTTP 401
/api/protected HTTP 401
```
**같은 위조 헤더인데 결과가 갈린다.** 위조 헤더가 `/api/echo` 를 열어 준 것이 아니라 거기는 원래 `permitAll` 이라 열려 있었다. `/api/me` 는 `401` 이고, 헤더로는 인증이 안 된다.
가이드는 이것을 앞선 실험과 이어 붙인다.
> 2홉 실험에서 헤더 위조로 `serverName: evil.example.com` 을 만든 것과 같은 종류다. 거기서는 쿠키 속성이었지만 여기서는 신원 그 자체다.
`permitAll` 과 `401` 을 가르는 설정은 backend 의 `SecurityConfig` 에 있다.
```text
backend SecurityConfig:
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
.anyRequest().authenticated()
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
```
**둘째 주입은 IdP 쪽이 정말 바뀌었는지와 세션이 그대로인지를 같이 본다.** 바뀌지 않은 것을 「반영 안 됨」으로 읽지 않으려면 반드시 본다.
```bash label="[kc-lab-1] IdP 의 email 을 다시 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users/$UID -r keycloak-patterns --fields email
```
**`$UID` 는 손으로 `$USER_ID` 로 바꿔 친다.** 값을 담은 변수는 주입 5절 ③ 의 `USER_ID` 이고 `UID` 는 셸이 이미 쓰고 있는 읽기 전용 이름이라, 그대로 두면 `users/1000` 을 읽어 없는 사용자가 나온다.
실측은 이렇다(observed, `03-b4-role-propagation.txt`).
```text
=== [2] IdP 에서 email 을 바꾼다 (kubectl 출력) ===
변경 시각(UTC): 2026-09-04T07:53:32.000Z
IdP 의 값:
[ {
"email" : "changed-labuser@example.com"
} ]
oauth2-proxy 세션: 1 개 (그대로 살아 있다)
```
IdP 값은 바뀌었고 세션은 하나다. 이 두 줄이 있어야 다음 절의 옛 값을 「반영 안 됨」이라고 말할 수 있다. 세션 목록은 지우기 전에 항상 먼저 본다.
```bash label="[kc-lab-1] oauth2-proxy 세션 키를 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
```
```text
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
```
**키 이름이 `_oauth2_proxy-` 로 시작한다.** 밑줄로 시작하고 안쪽은 밑줄이다. `'oauth2-proxy*'` 같은 패턴은 하나도 안 맞고, 그러면 세션이 없다고 오독한 뒤 이어서 지우는 명령이 조용히 아무것도 안 지운다.
## 관찰
**① 동명 헤더 둘은 덮어쓰이지도 합쳐지지도 않는다.** 실측은 이렇다(observed, `01-header-handling.txt`).
```text
(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"]`** | **✔** |
이 절차 앞머리에 옮긴 Q4 의 한 줄이 이 표에서 반만 남는다. 합치지 않는다는 맞았고 덮어쓴다는 틀렸다. 그 줄에는 조건이 빠져 있었다. 조건은 주입 전 4 절에서 읽은 `proxy_set_header` 여섯 줄에 그 이름이 있느냐이고, `X-Auth-Request-*` 는 거기 없었다.
엣지가 `X-Auth-Request-Roles: viewer` 를 붙여도, 공격자가 같은 헤더를 `admin` 으로 함께 보내면 둘 다 업스트림에 도착한다.
```text
edge 가 붙인 것: X-Auth-Request-Roles: viewer
공격자가 보낸 것: X-Auth-Request-Roles: admin
upstream 이 받는 것: ["viewer","admin"] 또는 ["admin","viewer"]
└─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다
```
Spring 의 `request.getHeader()` 는 첫 번째를 돌려주고, 그 순서는 프록시가 정한다. 애플리케이션 코드 어디에도 이 결정이 안 적혀 있다.
**② 값 안의 쉼표는 구분자와 구별되지 않는다.** 실측은 이렇다(observed, 같은 파일).
```text
(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)가 도착 시점에 똑같이 생겼다. 둘 다 값이 하나인 배열이고 그 안에 쉼표가 있다.
```text
"admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다
"role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다
```
role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다. Keycloak 의 role 이름은 임의 문자열이므로 애플리케이션이 「쉼표 쓰지 마세요」라고 정할 수 있는 조건이 아니다.
| 대안 | |
|---|---|
| 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. 다만 위조와 구별이 안 된다 |
| Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다 |
| **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 위조도 구분자도 해결된다 → BFF 구조 |
**③ 크기는 절벽에서 떨어진다.** 8000 에서는 Tomcat 의 HTML 오류 페이지가 온다. JSON 이 아니라 HTML 이라는 것 자체가 애플리케이션까지 갔는데 파싱 전에 잘렸다는 신호다.
```text
=== Q4 ② 헤더 크기 상한 ===
보낸 길이 1000 → HTTP 200, 도착 길이 1000
보낸 길이 4000 → HTTP 200, 도착 길이 4000
보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지)
보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김)
보낸 길이 32000 → HTTP 000
→ 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.
```
`000` 과 `400` 이 다른 값이다(observed, 같은 파일).
| curl 이 찍는 값 | 뜻 |
|---|---|
| `400` | 응답을 받았다. 서버가 거부했다 |
| `000` | 응답 자체를 못 받았다. 연결이 끊겼거나 아예 안 열렸다 |
| 크기 | 누가 거부하나 | 클라이언트가 보는 것 |
|---|---|---|
| ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 |
| ~16KB 이상 | **nginx** (`large_client_header_buffers`) | 응답 없음 / 연결 끊김 |
두 실패가 전혀 다르게 보인다. `400` 은 애플리케이션 오류처럼 보여 앱 로그를 뒤지게 하고, `000` 은 네트워크 장애처럼 보여 방화벽을 뒤지게 한다. 헤더가 커진 것은 같은데 진단이 갈린다.
```text
role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400
```
점진적으로 나빠지지 않는다. 그 절벽은 사용자마다 다르다 — role 이 많은 사용자만 깨지고 테스트 계정으로는 영원히 안 보인다.
**④ 위조한 신원은 검증 없이 도착한다.** 주입 검증에 실은 네 줄이 그 결과이고, 같은 헤더가 `/api/me` 에서 `401` 인 것도 거기 같이 적었다.
```text
JWT 경로 → 서명이 있다 → 검증할 대상이 있다 → 위조가 안 된다
헤더 경로 → 서명이 없다 → 검증할 대상이 없다 → ★ 위조를 구별할 방법이 없다
```
`request.getHeader("X-Auth-Request-User")` 는 그 값이 어디서 왔는지 모른다. 엣지가 붙였는지 클라이언트가 붙였는지 구별할 정보가 값 안에 없다.
Q4 가 확인한 사실로 적어 둔 다른 한 줄은 그대로 성립했다.
> *"upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을 검증할 방법이 없다"*
**⑤ 클레임 변경은 요청 횟수로는 반영되지 않는다.** 먼저 바꾸기 전 값을 브라우저에서 잰다 — **아래 첫 블록은 주입 5절 ③ 을 치기 전에 돌린다.** 여기까지 읽고 나서 처음 돌리면 이미 email 을 바꾼 뒤라 「변경 전」을 잰 것이 아니다. `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]);
}
```
```text
=== [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
```
바꾼 뒤 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));
}
```
실측은 이렇다(observed, `03-b4-role-propagation.txt`).
```text
=== [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줄이 전부 같다. 몇 번째 요청부터 반영되는지를 물었는데 답은 요청으로는 안 된다는 것이고, 요청 횟수가 아니라 세션의 나이가 정한다. 가이드가 적은 값은 **12회 · 약 6.4초**다.
**이 결론은 시계를 보정해야 성립한다.** 실측은 이렇다(observed, 같은 파일).
```text
=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
개발 머신(브라우저 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초 뒤다.
```
D 층의 「106초」와 이 「약 107초」는 같은 왜곡을 두 번 쟀다. 둘 다 `test-server` 가 dev 머신보다 앞선 양을 말한다. 여기서는 타임스탬프 둘의 차(`07:54:07 07:52:20`)로 어림했고, D-4a 에서는 ACME 응답을 제3의 기준으로 두고 다시 쟀다. 보정에 쓸 값은 D-4a 의 106초이고 여기 107초는 이 절의 12회를 읽기 위한 어림이다. 두 값을 섞어 빼지 않는다.
보정 전에는 12회의 타임스탬프(`07:51:56~`)가 변경 시각(`07:53:32`)보다 앞서 보인다. 그대로 읽으면 변경 전에 잰 것이 되어 결론이 통째로 무너진다. 보정하면 12회는 변경 11초 뒤이고, 그래야 변경 후에도 옛 값이라는 말이 선다. 자기 환경의 어긋남은 `date -u '+%Y-%m-%dT%H:%M:%SZ'` 와 브라우저 콘솔의 `new Date().toISOString()` 을 견줘서 잰다. 두 기계의 로그를 나란히 놓기 전에 시계를 확인한다 — D-4 도 이 확인을 안 해서 인증서 공백을 처음에 잘못 계산했고 나중에 **38분 25초**로 정정했다.
세션을 지우고 재인증시키면 새 값이 온다. 지우기 전에 목록을 본다. 가이드가 두 번째 줄을 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] 세션을 보고 지운다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' \
| xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
```
```text
=== [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
```
새 값이 나오고, 로그인 화면은 안 떴다(observed). Keycloak SSO 가 살아 있어 조용히 재인증됐다.
```text
변경 후 12회 요청(6.4초) → labuser@example.com (옛 값)
세션 삭제 후 재인증 → changed-labuser@example.com (새 값)
```
세션은 로그인 시점의 스냅샷이다. 로그인할 때 IdP 가 준 클레임을 세션에 담고, 이후 요청은 세션에서 읽어 헤더로 내보내며 IdP 를 다시 부르지 않는다. 그래서 IdP 에서 바꿔도 세션은 모른다. 지금 구성(`--cookie-refresh` 없음)에서는 쿠키 만료(1시간) 또는 재인증까지 안 되고, `--cookie-refresh=5m` 이면 최대 5분이라고 가이드가 적는다. 다만 그 5분은 설정의 정의이지 이 실험대에서 잰 값이 아니다(unknown). 권한을 뺏는 변경이 최대 1시간 늦게 반영되므로, 즉시 반영이 필요하면 헤더 방식은 맞지 않는다.
**nginx 에서 동명 헤더를 먼저 지우는 것이 그 처방이고, 이 실험대는 그 수정을 적용한 적이 없다**(unknown). 해설 문서 6절이 「남긴 것」으로 분류한 항목이고, 가이드는 「아래는 미검증이며, 적용하려면 랩 호스트에서 사람이 직접 친다」로 못박는다. 적용한다면 랩 호스트(`test-server`)에서, 백업을 먼저 뜬다.
```bash label="[test-server] 설정을 백업하고 편집기로 연다"
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 는 자기가 설정하지 않은 헤더를 덮어쓰지 않으니 덮어쓰게 하려면 먼저 설정해야 하고, 붙일 값이 없을 때 설정하는 방법이 빈 문자열이다. `proxy_set_header X-Auth-Request-Roles "";` 는 nginx 에서 그 헤더를 업스트림으로 보내지 않는다는 뜻이다. 엣지가 진짜 값을 붙여야 한다면 지운 뒤에 다시 설정한다 — 순서가 반대면 클라이언트 값이 살아남는다.
```bash label="[test-server] 문법을 검사하고 reload 한다"
sudo nginx -t && sudo systemctl reload nginx
```
`nginx -t` 의 마지막 줄에서 `syntax is ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 은 통과를 막지 않는다. 실패면 `&&` 가 reload 를 막아 준 것이고 지금 돌고 있는 nginx 는 옛 설정 그대로다. 고쳐졌는지는 동명 헤더 두 개를 보낸 명령을 똑같이 다시 쳐서 보고, 대조군과 같아지면(아무것도 안 나오면) 고쳐졌다. **이 실험대는 여기까지 재지 않았다**(unknown).
가이드는 판정 규칙을 하나 더 붙인다. 값이 그대로 나오면 reload 가 안 갔거나 다른 `server` 블록을 고친 것이고, reload 가 실제로 갔는지는 워커 PID 가 바뀌었는지로 본다.
```bash label="[test-server] 워커 PID 로 reload 가 갔는지 본다"
systemctl status nginx --no-pager | head -20
```
D-4a 가 같은 판정법을 인증서 갱신에 쓴다.
## 복구와 원상복구 확인표
### 1. IdP 값을 되돌린다
**목적** — 바꿔 둔 email 을 원래 값으로 돌린다.
① 사용자 id 를 다시 잡고 값을 되돌린 뒤 같은 명령으로 읽는다.
```bash label="[kc-lab-1] email 을 되돌리고 다시 읽는다"
USER_ID=$(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)
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update users/$USER_ID -r keycloak-patterns -s email=labuser@example.com
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users/$UID -r keycloak-patterns --fields email
```
**마지막 줄의 `$UID` 도 손으로 `$USER_ID` 로 바꿔 친다.** 앞에서와 같은 이유로, 셸이 이미 쓰고 있는 읽기 전용 이름을 그대로 두면 `users/1000` 을 읽어 없는 사용자가 나온다. 가이드는 이 편 전체에 대해 「명령은 같고 변수 이름만 다르다」로 적어 두었다. 아래 확인표의 IdP 행도 같이 바꿔 읽는다.
**예상 결과** — `"email" : "labuser@example.com"` 이 나와야 한다.
**왜 필요한가** — 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다. 세션을 한 번 더 지우면 확실하다.
**문제가 생기면** — `uid=` 가 빈 값이면 `--format csv --noquotes` 출력의 마지막 줄이 아닌 다른 줄을 잡은 것이다.
### 2. Grafana Ingress 를 돌려준다
**목적** — `app2` 를 잡고 있는 Ingress 를 하나로 만든다.
**이 편은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 뜬다.** 같은 Grafana Ingress 를 빌리는 다른 편들(B-7·B-7a·C-1·C-2)은 `~/grafana-ingress-backup.yaml` 을 읽는다. 경로가 다르므로 여기서 빌린 채로 그 편들로 넘어가면 복구가 `no such file` 로 죽고 **Grafana 가 안 열리는 채로 끝난다.** 이어서 갈 거면 이 절을 먼저 끝내 Grafana Ingress 를 돌려놓는다. 그리고 `/tmp` 는 재부팅으로 날아가므로 이 편을 이틀에 나눠 치지 않는다.
① oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린다.
```bash label="[kc-lab-1] ① Ingress 를 돌려준다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f /tmp/grafana-ingress-backup.yaml
```
② 하나만 남았는지 확인한다.
```bash label="[kc-lab-1] ② app2 를 잡고 있는 Ingress 를 센다"
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 을 이어서 할 거라면 그편이 낫다고 가이드가 적는다.
**문제가 생기면** — app2 가 Grafana 도 프록시도 아닌 것을 주면 Ingress 가 둘 다 남아 있다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| app2 | `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 '…\[[^]]*\]'` 로 대괄호째 뽑는다 |
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep -o` 로 뽑거나 응답을 통째로 본다 |
| `python3 -m json.tool` 을 쓰라고 되어 있다 | 해설 문서 7절의 형태다. 값 생성도 `python3 -c` 였다 | `head -c N \/dev\/zero \| tr '\0' 'r'` |
| 16000 에서 `000` 이 나온다 | **오류가 아니라 측정 결과다.** nginx 가 연결을 끊는다 | `400`(Tomcat)과 `000`(nginx)을 구별한다 |
| `sudo grep` 이 빈 결과 | **호스트 sudo 는 비밀번호를 요구한다** | `sudo -n -l` 로 확인 |
| Redis 에서 세션이 안 보인다 | 패턴이 틀렸다. 키는 **`_oauth2_proxy-`** 로 시작한다 | 먼저 `--scan` 만 쳐서 이름을 눈으로 본다 |
| `xargs … del` 이 아무것도 안 지운다 | 같은 원인. 패턴이 안 맞으면 **조용히** 0건 | 목록 개수와 `del` 반환 개수를 대조 |
| `curl -b` 로 로그인 상태가 재현이 안 된다 | **쿠키가 `HttpOnly` 다.** 꺼낼 수 없다 | 브라우저 콘솔에서 잰다 |
| 12회가 **변경 시각보다 앞서** 보인다 | **두 시계가 107초 어긋나 있었다** | 보정값을 먼저 잰다 |
| 값이 안 바뀐다 | **버그가 아니다.** 세션이 새로 만들어져야 한다 | `--cookie-refresh` |
| app2 가 Grafana 도 프록시도 아닌 것을 준다 | Ingress 가 **둘 다 남아 있다** | `get ingress -A \| grep app2` |
| `/api/me` 가 `200` 이다 | 위조가 통한 것이 **아니라** 진짜 JWT 를 보낸 것이다 | 헤더만 보냈는지 다시 본다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:23 KST`(①②④)와 `07:5107:53 UTC`(③)에 돈 실행에서 나왔다(observed).
- (observed) 동명 헤더 두 개가 `['admin', 'editor']` 로 둘 다 도착한 것, 쉼표 구분 (a)와 값 안 쉼표 (c)가 도착 시점에 구별되지 않는 것, 크기 훑기 다섯 줄(`1000`·`4000` 은 `200`, `8000` 은 `400`, `16000`·`32000` 은 `000`), 인증 없이 보낸 위조 신원 세 줄과 `remoteAddr` `100.123.124.30`, JWT 를 요구하는 경로의 `/api/echo 200` · `/api/me 401` · `/api/protected 401`, IdP 변경 시각 `2026-09-04T07:53:32.000Z` 와 바뀐 값, 변경 후 12회의 타임스탬프와 값 전부, 두 시계가 약 107초 어긋난 것, 세션 삭제 후 3회의 새 값, `_oauth2_proxy-f6a9201fd534a047998278452001ccbf` 의 `ttl=3568초` · `크기=3510바이트`.
- **경로를 혼동하지 않는다**(observed) — 위조 헤더를 잰 곳은 `app1.hyeonworks.com/api` 의 echo 앱이고 그 경로는 `permitAll` 이라 oauth2-proxy 를 거치지 않는다. 헤더가 도착한 것과 인가가 뚫린 것은 다른 사건이고, 같은 헤더를 `/api/me` 에 보내면 `401` 이다.
- (unknown) `proxy_set_header X-Auth-Request-* "";` 수정. **이 실험대는 그것을 적용한 적이 없다.** 가이드가 「적용하려면 랩 호스트에서 사람이 직접 친다」로 못박았고, 적용한 뒤 다시 재는 절도 미검증이다. 그러므로 위조가 막히는지는 이 문서 어디에도 측정으로 없다.
- (unknown) `grep -o` 로 헤더 배열을 뽑는 줄들, `head -c N /dev/zero | tr '\0' 'r'` 로 긴 값을 만드는 줄, 크기 훑기 루프, `xargs -r … redis-cli del` 로 세션을 지우는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트와 `python3 -c` 를 썼다.
- (unknown) `--cookie-refresh=5m` 을 켰을 때의 「최대 5분」. 설정의 정의이지 이 실험대에서 잰 값이 아니다.
- 이 실험이 재지 않은 것 — `X-Auth-Request-Roles` 자체의 반영 시점은 재지 않았다. role 을 헤더로 내보내려면 추가 설정이 필요해 `x-forwarded-email` 로 대체했고, 클레임 변경이 언제 반영되는가는 어느 클레임이든 같다는 것이 그 근거다. 업스트림의 내부 credential 검증을 공통 경계로 옮기는 것도 코드 변경이라 이 실험 밖이다.
<!-- body:end -->
@@ -0,0 +1,676 @@
---
id: d53181b9-bb28-4703-97ed-98adfb5b18dc
kind: SETUP
slug: reproduce-b7-cookie-secret-rotation
title: cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/d53181b9-bb28-4703-97ed-98adfb5b18dc/edit"
pinnedVersions:
- name: Redis
version: 7.4.x
- name: curl
version: 8.5.0
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다
oauth2-proxy 의 cookie secret 을 A 에서 B 로 바꿨을 때 로그인해 있던 세션이 어떻게 되는지 보는 절차다. Grafana 의 Ingress 를 빌려 띄우고 결과를 로그로 가른다. 남의 도메인을 빌리므로 끝나면 반드시 돌려준다. 약 20분.
## 관계
- **쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션**
이 절차가 만드는 고아 세션을 그 기록이 결론으로 적는다. 여기서는 그것이 생기는 데까지만 친다.
- **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다**
로그인 뒤 업스트림이 받는 `x-forwarded-*` 네 줄을 그 기록은 밖에서 위조해 보내 통과시켰다.
- **TTL 로 고아 세션을 골라내 지운다**
같은 배포를 이어 쓰지만 치는 것이 다르다. 여기는 회전을 A → B 로 한 번만 치고 고아가 생기는 데까지 보며, 그쪽은 1차와 2차를 자기 손으로 치고 기동 로그의 `refresh:disabled` 를 맨 앞에서 확인한 뒤 TTL 을 역산해 고아를 골라 지운다. 그 확인이 깨진 환경에서는 그쪽 절차를 쓰면 안 된다. 되돌리기도 다르다 — 여기는 secret 참조와 Grafana Ingress 를 되돌리고, 그쪽은 지운 세션이 돌아오지 않는다.
- **서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다**
같은 회전을 식별자가 있는 쪽에서 치는 편이다. 거기서는 토큰 헤더의 `kid` 가 겹치는 구간을 만들어 줬고, 여기서는 그 식별자가 없어 겹칠 수단 자체가 없다.
- **아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다**
먼저 해 둬야 하는 편이다. 이 절차가 세션을 넣는 Redis 를 거기서 띄운다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기계가 둘이고 표시가 둘이다. `kubectl``redis-cli``[kc-lab-1]` 에서 치고, 앞단 nginx 를 건너뛰고 Traefik 을 직접 두드리는 `curl``sudo``[test-server]` 에서 친다. 앞단 nginx 가 그 호스트에 있고 `192.168.122.11:80` 으로 넘겨주므로, 호스트에서 그 주소를 바로 치면 nginx 를 건너뛴다.
브라우저도 필요하다. 쿠키가 `HttpOnly` 이고 OIDC(OpenID Connect, OAuth2 위에 신원 확인을 얹은 규격) 흐름을 폼까지 걸어야 세션이 생긴다. `curl` 로 완주하려던 시도는 실패했다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · Grafana 는 `observability` |
| 빌리는 이름 | `app2.hyeonworks.com` — 평소 Grafana 로 간다 |
| 주입 수단 | `patch deployment``secretKeyRef.key``COOKIE_SECRET_A` 에서 `COOKIE_SECRET_B` 로 바꾼다 |
| 세션 저장소 | Redis. `redis.keycloak-lab.svc:6379` |
| replica | oauth2-proxy 파드 둘. 서로 다른 노드 |
| 시각 표시 | oauth2-proxy 로그는 UTC, `kubectl` 출력은 KST. 회전 시각을 UTC 로 적는다 |
| 전 구간 | 약 20분 |
| 도구 | `jq``yamllint` 도 이 실험대에 없다 |
## 이 실험이 가르는 것
앞선 작업이 남긴 열린 질문 Q1 의 미지수 7 이 이렇게 물었다.
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고 교체하게 되는가.
> 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다. 토큰 헤더에 `kid` 가 있어서 읽기는 여러 키, 쓰기는 하나가 됐기 때문이다.
| 무엇을 기대했나 | 무엇이 나왔나 |
|---|---|
| B-6 의 모양대로라면 oauth2-proxy 도 겹치는 구간을 만들 수 있을 것 | `--cookie-secret` 은 단수이고 쿠키에 키 식별자가 없다 |
예측하지 않았던 것이 하나 더 나온다. 사용자는 아무것도 못 느끼는데 서버 쪽에 지워지지 않는 세션이 생긴다. 그 「지우지 못한다」를 이어서 재는 것이 B-7a 이고, 이 절차는 거기까지 가지 않는다.
두 구조는 인가 요청을 어디에 두는지가 다르다.
```text
BFF 인가 요청을 서버 메모리(HttpSession)에 둔다 → replica 를 넘으면 실패
oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다 → replica 를 넘어도 성공
대신 secret 이 단일 지점
```
절차를 끝까지 밟으면 `--cookie-secret` 이 단수라는 도움말 한 줄, 회전만으로는 Redis 가 그대로인 것, 로그인 화면이 안 뜨는데 로그에는 재인증이 찍혀 있는 것, 세션 키가 하나에서 둘로 늘어난 것, 흐름을 시작한 파드와 콜백을 받은 파드가 다른데도 성공한 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다.
- B-0 이 끝나 있어야 한다. Redis 를 거기서 띄우고 `redis.keycloak-lab.svc:6379` 로 떠 있다.
- 브라우저가 있어야 한다.
**이건 남의 도메인을 빌리고 남의 세션을 끊는 실험이다.** 둘을 건드린다. 인증서가 `auth` · `app1` · `app2` 세 이름만 덮어서 네 번째 이름을 못 만들기 때문에 Grafana 의 Ingress 를 잠시 내리고 `app2` 를 빌린다. 그리고 secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다.
되돌리기는 둘이고 먼저 읽어 둔다.
```bash label="[kc-lab-1] ① secret 참조를 A 로 되돌린다"
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"}]'
```
①의 `env/1` 은 매니페스트의 env 배열 순서에 달린 숫자다. 중간에 멈춰서 되돌리는 사람은 주입 2 절 ①의 `env[*].name` 을 먼저 쳐서 `OAUTH2_PROXY_COOKIE_SECRET` 이 0 부터 세어 몇 번째인지 보고, 두 번째가 아니면 위 경로의 `1` 을 그 숫자로 바꾼다. 숫자가 틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.
```bash label="[kc-lab-1] ② 빌린 Ingress 를 걷고 Grafana 것을 올린다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
```
②가 올리는 백업 파일은 아래 주입 전 1 절이 만든다. B-4 로 app2 를 먼저 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로, `~` 쪽이 없을 때 그쪽을 본다.
## 주입 전에 같은 명령으로 먼저 본다
```text
Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양
```
### 1. 지금 app2 가 무엇인지 보고 Grafana Ingress 를 백업한다
**목적** — 실험이 끝났을 때 되돌릴 파일을 만든다.
**행동** — 먼저 지금 상태를 보고, 백업을 뜨고, 그 백업이 비어 있지 않은지 확인한다.
```bash label="[test-server] ① 지금 app2 가 어디로 가는지 본다"
curl -sI https://app2.hyeonworks.com/ | head -3
```
**②를 치기 전에 읽는다.** 셸은 `>` 를 kubectl 보다 먼저 처리한다. `~/grafana-ingress-backup.yaml` 은 kubectl 이 돌기도 전에 0바이트가 되고, `get` 이 실패하면 앞서 떠 둔 백업이 그때 없어진다. 뒤따르는 `wc -l` 과 `grep -c` 는 이미 비어 버린 파일을 센다. 그래서 이 절을 두 번째로 치는 사람은 — B-4 로 app2 를 먼저 빌렸거나 실험을 중간에 다시 시작했다면 — `wc -l ~/grafana-ingress-backup.yaml` 을 먼저 쳐서 쓸 만한 백업을 이미 갖고 있는지 보고, 갖고 있으면 ②를 건너뛴다.
```bash label="[kc-lab-1] ② 백업을 뜨고 내용이 있는지 센다"
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
```
**예상 결과** — ① 은 Grafana 로 가고 있으면 `302` 로 `/login` 을 가리킨다. ② 는 줄 수가 0 이 아니고 `app2.hyeonworks.com` 이 1회 이상 잡힌다.
**왜 필요한가** — 백업 파일이 빈 채로 원본을 지우면 복구할 것이 없다. `wc -l` 과 `grep -c` 가 그 사고를 여기서 막는다.
**문제가 생기면** — 둘 중 하나라도 `0` 이면 그대로 진행하지 않는다. 네임스페이스와 Ingress 이름을 다시 본다. Grafana Ingress 자체가 없다고 나오면 B-4 가 먼저 app2 를 빌려 갔다. 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽을 본다. 양쪽 다 비어 있으면 여기서 멈춘다 — 지금은 떠 둘 원본이 없고, 지운 Ingress 를 되살리는 절차는 가이드에 없다(unknown).
### 2. Grafana Ingress 를 내리고 oauth2-proxy 를 배포한다
**목적** — `app2.hyeonworks.com` 을 oauth2-proxy 쪽으로 돌린다.
**행동** — 내리고, 올리고, 롤아웃이 끝날 때까지 기다린다. ②의 `deploy/lab/k8s/b7-oauth2-proxy.yaml` 은 저장소 체크아웃의 루트에서 푸는 상대 경로다. 체크아웃을 어디에 뒀는지는 가이드에 없으므로(unknown), 그 경로가 풀리는 디렉터리로 옮긴 다음 ②를 친다.
```bash label="[kc-lab-1] ① Grafana Ingress 를 내린다"
kubectl -n observability delete ingress grafana
```
```bash label="[kc-lab-1] ② oauth2-proxy 를 배포한다"
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**예상 결과** — 실측은 이렇다(observed, `01-deploy.txt`).
```text
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
grafana ingress 삭제
```
```text
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
```
**왜 필요한가** — 같은 이름을 두 Ingress 가 주장하면 어느 쪽으로 갈지가 컨트롤러 판단에 맡겨진다. 내리는 것이 먼저다.
**문제가 생기면** — 매니페스트를 못 찾는다고 끝나면 체크아웃 루트가 아닌 디렉터리에서 쳤다. 롤아웃이 타임아웃이면 `kubectl -n keycloak-lab get pods -l app=oauth2-proxy` 로 파드 상태부터 본다.
### 3. replica 가 둘인지, 어느 노드에 있는지 본다
**무엇을 보는가** — 파드 수와 배치.
```bash label="[kc-lab-1] 파드 배치를 본다"
kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
**어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`).
```text
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
```
**이 값이 뜻하는 것** — 파드 두 개가 서로 다른 노드에 있다. 파드 이름의 끝 다섯 글자를 적어 둔다. 관찰 절에서 어느 replica 가 무엇을 했는지 그 글자로 가른다. replica 가 하나면 「공유」라는 말이 성립하지 않는다.
### 4. 진입점 두 곳이 갈라지는지 본다
**무엇을 보는가** — 인증을 거치는 경로와 안 거치는 경로.
```bash label="[test-server] 두 경로의 상태 코드를 뽑는다"
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
```
**어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`).
```text
=== 진입점 확인 ===
https://app2.hyeonworks.com/ HTTP 302
/ping HTTP 200
```
| 경로 | 정상 | 뜻 |
|---|---|---|
| `/` | `302` | 인증이 없으니 Keycloak 으로 보낸다 — 프록시가 일하고 있다 |
| `/ping` | `200` | 인증을 거치지 않는 헬스 경로 — 프록시 자체는 살아 있다 |
**이 값이 뜻하는 것** — `/ping` 도 안 되면 프록시가 안 떴고, `/ping` 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있다.
### 5. 502 를 만나면 한 겹씩 벗겨 좁힌다
**무엇을 보는가** — 502 를 낸 것이 앞단 nginx 인지, 그 뒤 Traefik 인지, 파드인지. 원래 구성에서 콜백이 계속 502 였다.
```bash label="[test-server] 앞단 nginx 를 건너뛰고 Traefik 에 바로 친다"
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/
```
**어디를 보나** — 실측은 이렇다(observed).
```text
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 label="[kc-lab-1] 세션 저장소가 Redis 인지 인자에서 본다"
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session
```
모양은 이렇다(모양은 observed).
```text
"--session-store-type=redis"
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"
```
nginx 설정을 직접 보려던 시도는 계속 빈 결과였다. 호스트에서 무언가가 빈 결과를 주면 먼저 이것을 친다.
```bash label="[test-server] 빈 결과의 원인이 권한인지부터 본다"
sudo -n true
```
실측은 이렇다(observed).
```text
$ sudo -n true
sudo: a password is required
```
`test-server` 의 sudo 는 비밀번호를 요구한다. 게스트(`kc-lab-1` 과 `kc-lab-2`)는 무암호라 A층에서 `conntrack` 과 `tc` 를 문제없이 썼는데 호스트는 다르다. 앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가 조용히 실패한 것이었다.
### 6. secret 의 키 이름과 길이를 본다
**무엇을 보는가** — 회전 대상이 준비되어 있는지. 값은 찍지 않는다.
```bash label="[kc-lab-1] ① Secret 의 키 이름만 뽑는다"
kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'
```
모양은 이렇다(모양은 observed).
```text
"CLIENT_SECRET"
"COOKIE_SECRET_A"
"COOKIE_SECRET_B"
```
길이도 본다. 가이드가 아래 두 줄을 미검증으로 표시했다(unknown) — 원래 실행 기록에 이 명령의 출력이 없다.
```bash label="[kc-lab-1] ② 두 cookie secret 의 길이만 센다 (unknown)"
kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
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바이트짜리이고, 다른 수가 나오면 프록시가 기동에서 죽는다.
**이 값이 뜻하는 것** — 회전 대상이 미리 두 개 준비되어 있고, 그래서 이 실험이 한 번 바꾸고 되돌릴 수 있는 형태가 된다. 16 · 24 · 32 라는 제약은 oauth2-proxy 의 것이지 이 실험대가 잰 값이 아니다.
### 7. 브라우저로 로그인하고 업스트림이 받는 헤더를 본다
**무엇을 보는가** — 세션이 생겼는지, 그리고 프록시가 업스트림에 무엇을 붙이는지.
브라우저에서 `https://app2.hyeonworks.com/api/echo` 를 열고 `labuser` / `labpass` 로 로그인한다. Keycloak 로그인 화면이 뜨고, 통과하면 업스트림(echo)의 JSON 이 보인다.
**어디를 보나** — 실측은 이렇다(observed, `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 의 결론이 그대로 적용된다 — 엣지가 붙인 것과 공격자가 보낸 것을 업스트림은 구별하지 못한다.
### 8. 세션이 Redis 에 들어갔는지, 쿠키가 티켓인지 본다
**무엇을 보는가** — 한 번은 통째로 본 다음 접두사로 좁힌다.
```bash label="[kc-lab-1] ① Redis 전체를 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**어디를 보나** — 실측은 이렇다(observed, `03-rotation.txt`).
```text
=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
dbsize: 2
```
`dbsize` 는 2 인데 세션은 하나다. `b5:pvc` 는 B-5 가 남긴 키이고 이 실험과 무관하다. 이 Redis 는 이 실험 전용이 아니므로 `dbsize` 로 세션을 세면 틀린다.
```bash label="[kc-lab-1] ② 세션만 세려면 접두사로 좁힌다"
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
`KEYS` 대신 `--scan` 을 쓴다. `KEYS` 는 Redis 를 블로킹하고, 실험대에서는 티가 안 나지만 습관을 여기서 들인다.
**이 값이 뜻하는 것** — 마지막으로 쿠키가 티켓인지 확인한다. 세션 저장소를 Redis 로 옮기면 쿠키에는 세션 전체가 아니라 티켓만 담긴다. 브라우저 개발자 도구에서 Application 또는 저장소 → Cookies → `_oauth2_proxy` 로 본다. 값은 지금 쓸 수 있는 세션 자격증명이라 모양과 길이만 적는다(observed).
```text
_oauth2_proxy=<ticket>|<timestamp>|<mac>
└─ Redis 키를 여기서 계산한다
세션 전체가 아니라 티켓이다 (약 180자)
```
`|` 로 나뉜 세 토막과 전체 길이를 본다. 쿠키가 짧아졌고 그래서 502 가 사라졌다. Redis 키 이름은 이 티켓에서 계산되고, 관찰 절의 「지우지 못한다」가 그 관계에서 나온다.
## 주입
### 1. 겹칠 수 있는지부터 묻는다
**무엇을 보는가** — 회전을 치기 전에, 애초에 두 secret 을 동시에 들 수 있는지.
```bash label="[kc-lab-1] 도움말에서 cookie-secret 을 찾는다"
kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
/bin/oauth2-proxy --help 2>&1 | grep cookie-secret
```
**어디를 보나** — 실측은 이렇다(observed, `03-rotation.txt`).
```text
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
```
**이 값이 뜻하는 것** — `string` 이고 복수형이 아니다. `--cookie-secrets` 도 `--old-cookie-secret` 도 목록에 없으므로 겹치는 구간을 만들 수단이 아예 없다. B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 `kid` 가 있어서였고, oauth2-proxy 의 쿠키에는 그런 식별자가 없다.
```text
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 (B-6)
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 (B-7)
```
겹칠 수 있는가에는 이 한 줄이 답했고, 남은 절차는 그래서 실제로 무슨 일이 나는지를 본다.
### 2. env 인덱스를 확인하고 secret 참조를 A 에서 B 로 바꾼다
**목적** — Deployment 가 읽는 cookie secret 을 갈아치운다.
**행동** — patch 가 지목하는 `env/1` 이 맞는지 먼저 보고, 시각을 남기고, 바꾼다.
```bash label="[kc-lab-1] ① env 배열의 이름 순서를 본다"
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo
```
모양은 이렇다(모양은 observed).
```text
OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET
```
`OAUTH2_PROXY_COOKIE_SECRET` 이 몇 번째인지 0부터 센다. 위 모양에서는 두 번째이므로 `env/1` 이고, 순서가 다르면 patch 의 숫자를 고친다.
```bash label="[kc-lab-1] ② 시각을 UTC 로 남기고 참조를 바꾼다"
date -u '+%H:%M:%S UTC 회전'
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"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**예상 결과** — 실측은 이렇다(observed, `03-rotation.txt`).
```text
=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
```
**왜 필요한가** — `env/1` 은 매니페스트 순서에 달린 값이라 그대로 믿지 않는다. 틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다. 그리고 시각을 UTC 로 적어 두는 까닭은 프록시 로그가 UTC 이고 B-7a 의 정리 규칙이 이 값을 기준으로 고아를 고르기 때문이다.
**문제가 생기면** — patch 뒤에 프록시가 기동에서 죽으면 인덱스를 잘못 짚었다. `env[*].name` 순서를 다시 본다.
## 주입 검증
### 1. Deployment 의 참조가 실제로 바뀌었는가
```bash label="[kc-lab-1] 지금 참조하는 키 이름을 뽑는다"
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
실측은 이렇다(observed, `03-rotation.txt`).
```text
현재 secret 키: COOKIE_SECRET_B
```
바뀐 것은 Deployment 의 참조이지 Secret 의 내용이 아니다. 두 값 다 그대로 있고 어느 쪽을 읽을지만 바뀌었으며, 그래서 되돌리기가 한 줄이다.
### 2. Redis 는 그대로인가
```bash label="[kc-lab-1] 주입 전과 똑같은 줄을 친다"
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
실측은 이렇다(observed, `03-rotation.txt`).
```text
Redis 세션은 그대로인가: 2 키
```
**어디를 보나** — 나오는 줄의 개수를 주입 전 8 절 ②에서 본 것과 견준다. 위 실측의 「2 키」는 원래 실행이 적어 낸 숫자인데, 같은 실행이 8 절에서 접두사로 좁혀 본 세션은 `_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a` 하나였고 `b5:pvc` 를 더해야 둘이 된다. 원래 실행이 여기서 무엇을 셌는지는 기록에 없다(unknown). 따라 하는 사람 화면에는 `_oauth2_proxy-` 로 시작하는 줄이 8 절 ②와 같은 수만큼 나온다. 줄이 하나만 나와도 주입은 걸린 상태이고, 판정 기준은 숫자 2 가 아니라 회전 전과 같은 수인가다.
세션 수가 회전 전과 같다. 회전 자체는 아무 일도 일으키지 않으므로 여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면 누군가 옛 쿠키를 들고 와야 한다.
### 3. 파드가 실제로 새로 떴는가
```bash label="[kc-lab-1] 파드 이름이 바뀌었는지 본다"
kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
파드 이름이 주입 전과 다르다. 같으면 patch 가 아무 필드도 안 바꿨다. 이미 B 였거나 경로가 틀렸다.
그래서 주입 전 3 절에서 적어 둔 끝 다섯 글자는 여기서 쓸모가 없어진다. 이 출력에 나온 새 이름 둘의 끝 다섯 글자를 다시 적어 둔다 — 아래 관찰 절에서 어느 replica 가 흐름을 시작하고 어느 replica 가 콜백을 받았는지 그 글자로 가른다.
## 관찰
로그인했던 그 브라우저 그대로 `https://app2.hyeonworks.com/api/echo` 를 연다. 볼 것은 로그인 화면이 뜨는가다.
실측은 뜨지 않았다(observed). 화면이 잠깐 깜빡이고 그대로 열린다.
Keycloak SSO 세션이 살아 있어서 조용히 재인증이 일어났다. 쿠키는 분명히 무효가 됐는데 사용자 눈에는 아무 일도 없었다. **여기서 읽는 방향이 갈린다.** 「로그인 화면이 안 떴으니 교체가 무중단이구나」로 읽으면 정확히 뒤집어 읽는다. 쿠키는 죽었고 사용자는 실제로 재인증을 거쳤다. SSO 가 그 사실을 가려 준 것이고, IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.
로그가 무슨 일이 났는지 말한다. 먼저 최근 로그를 통째로 본다.
```bash label="[kc-lab-1] ① 최근 3분을 파드 이름과 함께 본다"
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix
```
`--prefix` 는 각 줄 앞에 파드 이름을 붙여 준다. replica 가 둘이므로 이것이 없으면 누가 무엇을 했는지 못 가린다. `--since=3m` 은 최근 3분만 보므로 브라우저로 접속한 뒤 3분을 넘겨 치면 아무 줄도 안 나온다. 그때 나온 빈 결과는 「로그가 없다」가 아니라 창을 놓쳤다는 뜻이니, 브라우저를 한 번 더 열고 곧바로 친다. 그다음 좁힌다.
```bash label="[kc-lab-1] ② 세션 저장소 쪽 줄만 좁힌다"
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session
```
실측은 이렇다(observed, `03-rotation.txt`).
```text
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
```
두 줄이 다른 말을 하고 있다.
| 줄 | 뜻 |
|---|---|
| `stored_session.go:94` | 쿠키를 열 수 없다 → 세션을 지우겠다 |
| `stored_session.go:97` | 그 지우기가 실패했다 → `error decoding ticket to clear session` |
94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다. 이어지는 줄이 사용자 쪽 이야기다(observed).
```text
[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` 가 같은 초에 있다. 로그인 흐름이 실제로 돌았고 사람 손이 안 들어갔다. 그 두 줄 사이에 화면이 깜빡였다.
그다음 Redis 를 본다.
```bash label="[kc-lab-1] 세션 키를 다시 센다"
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
실측은 이렇다(observed, `03-rotation.txt`).
```text
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
총: 2 개
```
키가 둘이다. 뒤엣것(`b26111f…`)은 회전 전의 세션이고 앞엣것은 방금 새로 생겼다. 사용자는 하나인데 서버 세션이 둘이다. 옛 것은 아무도 쓸 수 없고 프록시도 지우지 못한다.
못 지우는 까닭은 티켓과 키의 관계에 있다. Redis 세션 저장소를 쓰면 쿠키에는 티켓만 담기고, 티켓은 두 부분이다.
```text
티켓 = <세션 ID>.<암호화 키>
│ └─ 값을 복호화할 키
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
```
티켓 전체가 cookie secret 으로 봉인되어 있다. secret 을 바꾸면 티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다. 프록시는 「이 세션은 못 쓴다」까지는 알지만 그 세션이 Redis 어디에 있는지를 모른다. 그래서 `removing session` 을 시도하고 실패한다.
```text
secret 교체
└─ 옛 티켓을 못 푼다
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
```
로그인한 사용자 수만큼 고아가 생긴다. 이 절차는 여기서 멈춘다. 정말 사라지는지, 운영자는 지울 수 있는지, 어느 것이 고아인지는 B-7a 가 이어서 잰다.
덤으로, 로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.
```bash label="[kc-lab-1] 흐름을 시작한 파드와 콜백을 받은 파드를 가른다"
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
| grep -E 'Initiating login|AuthSuccess'
```
실측은 해설 문서에 이 모양으로 남아 있다(observed).
```text
--- 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
```
예시의 `8p5hl` · `b9928` 은 주입 전 3 절이 보여 준 회전 전 파드 이름과 같다. 회전이 파드를 새로 띄웠으므로 따라 하는 사람 화면에는 주입 검증 3 절에서 다시 적어 둔 이름이 나온다. 글자만 다르고 읽는 법은 같다.
시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.
| 어느 쪽인가 | 인가 요청(state, CSRF)을 어디에 두는가 | replica 간 |
|---|---|---|
| BFF | 서버 메모리(HttpSession) | 콜백이 다른 인스턴스로 가면 실패 (B-0) |
| oauth2-proxy | 쿠키 (secret 으로 봉인) | secret 만 같으면 성공 |
「어떻게 공유하는가」에 이 로그가 답한다 — replica 들이 나눠 가질 상태가 없고, 같아야 하는 값은 k8s Secret 하나다. 대신 그 하나가 단일 지점이 된다.
## 복구와 원상복구 확인표
### 1. secret 참조를 A 로 되돌린다
**목적** — 실험 전 상태로 돌린다.
**행동** — 시각을 남기고 되돌린다.
```bash label="[kc-lab-1] 되돌리는 것도 회전이다"
date -u '+%H:%M:%S UTC 되돌림'
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"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**예상 결과** — 롤아웃이 끝나고 파드 이름이 또 바뀐다.
**왜 필요한가** — 되돌리기도 회전이므로 B 로 만든 세션이 이번에는 고아가 된다.
**문제가 생기면** — 참조가 안 바뀌면 `env` 인덱스를 다시 본다.
### 2. 고아를 어떻게 할지 고른다
고아를 정리하는 선택지는 셋이다.
| 무엇을 | 언제 | 어떻게 |
|---|---|---|
| 그냥 둔다 | 실험대 | TTL(1시간)이 지나면 사라진다 |
| TTL 로 골라 지운다 | 산 세션을 살리고 싶을 때 | B-7a 의 규칙 |
| 전부 지운다 | 어차피 다 무효일 때 | 아래 |
전부 지울 때는 `b5:pvc` 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다. `FLUSHDB` 를 쓰지 않는다 — 이 Redis 는 BFF 세션도 담고 있다.
```bash label="[kc-lab-1] 접두사에 걸린 것만 지운다"
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
done
```
### 3. Grafana Ingress 를 돌려준다
**목적** — 빌린 도메인을 원래 주인에게 돌린다.
**행동** — oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린 뒤 밖에서 확인한다.
**지우기 전에 백업 파일이 손에 있는지 본다.** 지금은 Grafana Ingress 가 이미 없으니 파일이 비어 있으면 다시 뜰 원본도 없고, 되살리는 절차는 가이드에 없다(unknown). 아래 두 줄의 첫째가 줄 수를 내고 둘째가 `0` 이 아니면 그 파일로 돌려줄 수 있다. `No such file or directory` 가 나오면 ①을 치지 않는다 — B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽을 본다.
```bash label="[kc-lab-1] ⓪ 백업 파일이 쓸 만한지 본다"
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
```
```bash label="[kc-lab-1] ① 빌린 것을 걷고 백업을 올린다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
```
```bash label="[kc-lab-1 → test-server] ② Ingress 와 밖에서 본 응답을 함께 본다"
kubectl -n observability get ingress grafana
curl -sI https://app2.hyeonworks.com/ | head -3
```
첫 줄은 `kc-lab-1` 에서 치고, `curl` 은 1 절 ①과 같게 `test-server` 에서 친다. 기계가 다르면 1 절에서 본 응답과 견줄 수 없다.
**예상 결과** — Ingress 가 `observability` 에 다시 있고 `app2` 응답이 1 절에서 처음 본 모양으로 돌아온다.
**왜 필요한가** — 순서를 바꾸면 어느 쪽으로 갈지가 컨트롤러 판단에 맡겨진다. 그리고 되돌리지 않으면 실험이 끝나도 Grafana 가 안 열린다.
**문제가 생기면** — oauth2-proxy 전체를 걷어내려면 `kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml` 인데, B-7a 와 C-1 이 이 배포를 그대로 쓴다. 이어서 할 생각이면 남겨 두고, 그때는 Grafana Ingress 복구도 그 실험이 끝난 뒤로 미룬다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
| 파드 | `kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
| Redis | `redis-cli --scan --pattern '_oauth2_proxy-*'` | 남기기로 한 만큼만 |
| Ingress (빌린 것) | `kubectl -n keycloak-lab get ingress` | oauth2-proxy 것이 없다 (걷어냈다면) |
| Ingress (Grafana) | `kubectl -n observability get ingress grafana` | 있다 |
| 밖 | `curl -sI https://app2.hyeonworks.com/ \| head -3` | Grafana 로 간다 |
## 막히면
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 콜백이 `502 Bad Gateway` | 쿠키가 크다. `Set-Cookie` 가 nginx 버퍼를 넘겼다 | Traefik 직접이 200 인지. Redis 세션 저장소로 옮긴다 |
| 호스트에서 nginx 설정과 로그가 빈 결과 | `sudo` 가 조용히 실패했다 | `sudo -n true` → `sudo: a password is required` |
| `--cookie-secrets` 를 찾는데 없다 | 단수다. 겹치는 구간이 애초에 없다 | `--help \| grep cookie-secret` |
| patch 뒤 프록시가 기동에서 죽는다 | env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다 | `env[*].name` 순서 확인 |
| secret 을 바꿨는데 Redis 가 그대로 | 정상이다. 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 | 브라우저로 접근 |
| 로그인 화면이 안 떠서 「무중단」이라 읽었다 | SSO 가 재인증을 가렸다. 쿠키는 죽었다 | 로그의 `Initiating login` 과 `AuthSuccess` |
| 로그가 파드마다 섞여 못 읽겠다 | replica 가 둘이다 | `logs -l app=oauth2-proxy --prefix` |
| `dbsize` 로 세션을 셌더니 안 맞는다 | `b5:pvc` 등 다른 키가 섞인다 | `--scan --pattern '_oauth2_proxy-*'` |
| 파드 IP 로 `/oauth2/auth` 를 쳤더니 `HTTP 000` | 호스트에서 파드 IP 는 안 닿는다 | 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다 |
| `curl` 로 OIDC 흐름을 완주하려다 실패 | 쿠키가 `HttpOnly` 이고 폼을 거쳐야 한다 | 브라우저를 쓴다 |
| 로그 시각이 9시간 어긋난다 | 프록시 로그는 UTC | KST = UTC+9 |
| `app2` 가 Grafana 로 간다 | Ingress 를 안 만들었거나 이미 복구했다 | `kubectl -n keycloak-lab get ingress` |
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | oauth2-proxy Ingress 를 먼저 지우고 백업을 올린다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:3514:42 KST` 에 돈 한 번의 실행에서 나왔다(observed). 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과 수집 시각이 `14:3514:42 KST` 인 것은 같은 순간이다(KST = UTC+9). 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.
- (observed) Grafana Ingress 삭제 한 줄과 배포 출력 여섯 줄, 파드 두 개(`oauth2-proxy-c76b49c59-8p5hl` @ `kc-lab-1` · `oauth2-proxy-c76b49c59-b9928` @ `kc-lab-2`), 진입점 `/ HTTP 302` · `/ping HTTP 200`, Traefik 직접의 `200` 과 `302`, `sudo -n true` → `sudo: a password is required`, 로그인 뒤 업스트림이 받은 헤더 네 줄, 회전 전 Redis 의 `b5:pvc` · `_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a` · `dbsize: 2`, `--cookie-secret string` 도움말 한 줄, 교체 출력 두 줄과 `현재 secret 키: COOKIE_SECRET_B`, 교체 직후 `Redis 세션은 그대로인가: 2 키`, `stored_session.go:94` 와 `97` 두 줄과 `Initiating login` · `AuthSuccess`, 회전 뒤 Redis 의 키 둘과 `총: 2 개`, replica 를 갈라 본 로그.
- 비밀은 이름과 길이만 적었다. Secret 의 키 이름 셋(`CLIENT_SECRET` · `COOKIE_SECRET_A` · `COOKIE_SECRET_B`)과 16 · 24 · 32 바이트라는 제약만 옮겼고 값은 어디에도 안 적었다. 쿠키도 `<ticket>|<timestamp>|<mac>` 이라는 모양과 약 180자라는 길이만 옮겼다 — 지금 쓸 수 있는 세션 자격증명이라 원문은 해설 문서에 있다. Redis 키 이름과 파드 이름은 식별자라 그대로 적었다.
- (unknown) `COOKIE_SECRET_A` 와 `B` 의 길이를 재는 두 줄. 가이드가 미검증으로 표시했고 원래 실행 기록에 이 명령의 출력이 없다. 16 · 24 · 32 바이트라는 제약은 oauth2-proxy 의 것이지 이 실험대가 잰 값이 아니다.
- 예상이 빗나간 대목(observed) — 브라우저에서 로그인 화면이 안 떴다. 그것을 「무중단」으로 읽으면 뒤집어 읽은 것이고, 로그의 `Initiating login` 과 `AuthSuccess` 가 재인증이 실제로 돌았다는 값이다. IdP SSO 가 없거나 만료된 경우에 전원이 로그인 화면을 보는지는 재지 않았다(unknown).
- 이 절차가 재지 않은 것 — 고아가 정말 사라지는지, 운영자가 지울 수 있는지, 어느 것이 고아인지는 여기서 재지 않고 B-7a 로 넘겼다. 502 를 고치는 다른 길(nginx 의 `proxy_buffer_size` 를 키우는 것)도 재지 않았다. 세션을 Redis 로 옮기는 쪽만 쟀다.
<!-- body:end -->
@@ -0,0 +1,653 @@
---
id: 8d6b8a7f-e08e-4c9f-9772-a14a9d569260
kind: SETUP
slug: reproduce-b7a-orphan-session
title: TTL 로 고아 세션을 골라내 지운다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/8d6b8a7f-e08e-4c9f-9772-a14a9d569260/edit"
pinnedVersions:
- name: Redis
version: 7.4.x
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# TTL 로 고아 세션을 골라내 지운다
기동 로그에서 `refresh:disabled` 를 확인하고 cookie secret 을 두 번 회전시킨 뒤, TTL 을 역산해 고아 세션만 골라 지우는 절차다. 지운 세션은 돌아오지 않으므로 `del` 앞에 같은 루프를 `echo` 로 한 번 돌린다. 약 20분.
## 관계
- **쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션**
이 절차가 재는 것을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
여기 나오는 `R()` 함수와 `while` 루프들이 그 대상이다. 가이드가 전부 미검증으로 표시했고, 루프가 만든 TTL 숫자는 증거 파일에 있지만 그 값을 뽑아낸 형태는 확인되지 않았다.
- **두 시계에서 온 값을 빼지 않는다**
이 절차의 결론이 시각 계산이다. 프록시 로그는 UTC 이고 셸의 `date` 는 KST 라, 섞으면 9시간이 틀어진다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
같은 배포를 이어 쓰지만 치는 것이 다르다. 그쪽은 Grafana Ingress 를 빌려 oauth2-proxy 를 세우고 회전을 A → B 로 한 번만 쳐서 고아가 생기는 데까지 보며, 여기는 그 배포 위에서 1차와 2차를 직접 치고 TTL 로 고아를 골라 지운다. 전제도 다르다 — 여기는 `refresh:disabled` 확인이 첫 단계이고 그것이 깨진 환경에서는 이 절차를 쓰면 안 된다. 그쪽은 Ingress 까지 돌려주는 것으로 끝나고 여기는 지운 세션이 돌아오지 않는다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 `kubectl` 로 친다. Redis 에 묻는 것도 `kubectl exec deploy/redis` 를 거치므로 노드에 들어갈 일이 없다.
브라우저도 필요하다. 고아는 사람이 옛 쿠키를 들고 와야 생긴다. 셸만으로는 아무리 회전시켜도 키가 늘지 않는다.
시각은 전부 UTC 로 다룬다. 프록시 로그가 UTC 로 찍히고 이 절차의 결론이 시각 계산이라, `date``-u` 를 안 붙이면 9시간이 틀어진다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 대상 | Deployment `oauth2-proxy` replica 둘 · Deployment `redis` |
| 주입 수단 | `patch deployment``secretKeyRef.key` 를 바꾼다. 1차는 A → B, 2차는 B → A |
| 구분 신호 | TTL 하나. 이름도 `type``strlen` 도 같다 |
| 역산에 쓰는 값 | `cookie-expire``1h0m0s` 이므로 3600 |
| 전 구간 | 약 20분. 그중 TTL 을 세 번 재는 데 1분이 그대로 든다 |
| 도구 | `jq` 가 이 실험대에 없다. Redis 는 자기 CLI 로 묻는다 |
## 이 실험이 가르는 것
B-7 은 여기서 멈췄다.
```text
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
```
티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다. 그 문장을 그대로 믿으면 「고아는 어쩔 수 없다」가 된다. 못 지우는 주체가 누구인지는 거기서 안 갈랐다.
| 어느 기록이 | 무엇을 말하나 |
|---|---|
| B-7 이 남긴 말 | 「★ 지우지 못했다」 |
| 이 절차가 묻는 것 | 그것이 oauth2-proxy 의 한계인가, Redis 의 한계인가 |
답은 oauth2-proxy 쪽이다. 프록시는 티켓을 못 풀어 키를 계산 못 하지만 운영자는 키를 직접 안다 — `--scan` 하면 다 보인다. 그러면 다음 물음이 생긴다. 보이긴 하는데 어느 것이 고아인가. 이 절차가 실제로 재는 것이 그 판별이고 답은 TTL 하나다.
```text
(1) 고아의 TTL 은 정말 줄어드는가 — 사라지기는 하는가
(2) 운영자가 지울 수 있는가 — 지우면 산 세션이 다치는가
(3) ★ 어느 키가 고아인지 구분되는가 — 이것이 진짜 질문이다
```
절차를 끝까지 밟으면 두 키의 `strlen` 이 바이트 단위로 같은 것, TTL 이 30초에 30초씩 줄고 요청을 보내도 안 늘어나는 것, 역산한 생성시각과 로그의 `AuthSuccess` 가 1초 차인 것, 2차 회전에서 신분이 바뀌는 키가 하나 나오는 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- B-7 이 끝나 있다. oauth2-proxy 가 `app2.hyeonworks.com` 에서 돌고 있고 세션 저장소가 Redis 여야 한다.
- 브라우저가 있어야 한다.
- 기동 로그의 `refresh:disabled` 를 1 절에서 확인한다. **그것이 `disabled` 가 아니면 이 절차의 정리 규칙은 그 환경에서 성립하지 않는다.**
**이건 남의 세션을 실제로 지우는 실험이다.** `redis-cli del` 로 세션 키를 지우고, 산 사람의 세션을 잘못 지우면 그 사람은 재로그인해야 한다. SSO 가 살아 있으면 조용히 지나간다. 그 이상의 피해는 측정되지 않았지만 실험대에서만 한다.
되돌리기는 secret 참조를 A 로 되돌리는 한 줄이다. B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 절차가 끝난 뒤에 돌려준다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
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"}]'
```
## 주입 전에 같은 명령으로 먼저 본다
```text
프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계
```
### 1. refresh 가 꺼져 있는지 먼저 본다
**무엇을 보는가** — 기동 로그의 쿠키 설정 한 줄. 이 한 단어가 정리 규칙 전체의 전제다.
```bash label="[kc-lab-1] ① 기동 로그에서 쿠키 설정을 찾는다"
kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'
```
**어디를 보나** — 실측은 이렇다(observed, `b7-cookie-secret/03-rotation.txt`).
```text
[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초 | 역산식에 그대로 들어간다 |
| `refresh:disabled` | TTL 이 요청으로 갱신되지 않는다 | 이것이 `enabled` 면 역산이 무너진다 |
기동 로그가 잘려 나갔으면 인자에서 직접 본다.
```bash label="[kc-lab-1] ② 인자에서 쿠키 관련 값만 뽑는다"
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie
```
모양은 이렇다(모양은 observed).
```text
"--cookie-secure=true"
"--cookie-expire=1h"
```
**이 값이 뜻하는 것** — `--cookie-refresh` 가 목록에 없으면 `refresh:disabled` 다. TTL 이 고정이면 TTL 은 생성 시각의 정확한 함수가 된다. 여기가 `disabled` 가 아니면 이 절차의 뒷부분을 쓰지 않는다 — 오래 안 쓴 산 세션이 고아로 오판되어 지워진다.
### 2. 세션을 하나 만들고 Redis 를 통째로 본다
**무엇을 보는가** — 키 하나와 그 키의 세 가지 성질.
브라우저에서 `https://app2.hyeonworks.com/api/echo` 를 열고 `labuser` / `labpass` 로 로그인한다. 업스트림의 JSON 이 보이면 세션이 생겼다.
```bash label="[kc-lab-1] ① 세션 키와 전체 키 수를 본다"
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
[기준선] 회전 전 — 11:29:42 UTC
secret = COOKIE_SECRET_A
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
dbsize=1
```
그 키 하나에 대해 셋을 따로 묻는다. 나중에 루프로 묶더라도 처음에는 `type` · `ttl` · `strlen` 이 각각 무엇을 답하는지 봐 둔다. 키 이름은 위 출력에서 가져온다.
```bash label="[kc-lab-1] ② 한 키에 세 가지를 따로 묻는다"
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli type _oauth2_proxy-f6a9201fd534a047998278452001ccbf
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli ttl _oauth2_proxy-f6a9201fd534a047998278452001ccbf
kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli strlen _oauth2_proxy-f6a9201fd534a047998278452001ccbf
```
| 명령 | 답하는 질문 | 이 절차에서 |
|---|---|---|
| `type` | 무슨 자료형인가 | 전부 `string` — 구분에 못 쓴다 |
| `strlen` | 몇 바이트인가 | 전부 `3510` — 구분에 못 쓴다 |
| `ttl` | 몇 초 남았나 | 유일하게 다른 값 |
**이 값이 뜻하는 것** — `ttl` 이 `-1` 이면 만료가 안 걸린 키이고 이 절차의 대상이 아니다. `-2` 면 키가 없다 — 이름을 잘못 옮겼다.
### 3. 세 번씩 치는 대신 짧은 함수를 하나 둔다
**무엇을 보는가** — 키가 여럿이 됐을 때 같은 것을 한 줄로 보는 형태. 가이드가 이 함수와 아래 루프들을 미검증으로 표시했다(unknown).
**이 절은 건너뛸 수 없다.** 7 절부터 끝까지 모든 Redis 조회가 이 `R` 을 부른다. 그리고 셸 함수는 그 셸에만 있다 — 터미널이 끊기거나 다른 창에서 이어 치면 `R: command not found` 가 나오고, 그때는 이 블록의 첫 줄부터 다시 친다.
```bash label="[kc-lab-1] 이름 그대로 하는 한 줄짜리 함수 (unknown)"
R() { 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
```
모양은 이렇다(모양은 observed).
```text
_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510
```
**이 값이 뜻하는 것** — 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. 느리다. 키가 수백 개면 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다.
### 4. 시계를 맞춘다
**무엇을 보는가** — 셸의 시각과 UTC, 그리고 NTP 동기화 여부.
```bash label="[kc-lab-1] 로컬과 UTC 를 나란히 보고 동기화를 확인한다"
date; date -u
timedatectl show -p NTP -p NTPSynchronized
```
**어디를 보나** — 모양은 이렇다(모양은 observed).
```text
NTP=yes
NTPSynchronized=yes
```
**이 값이 뜻하는 것** — `NTPSynchronized=yes` 를 보고 나면 앞으로 `date` 는 전부 `-u` 를 붙여 친다. 그래야 로그의 `[2026/09/04 05:42:18]` 과 회전 시각을 같은 축에 놓을 수 있고, 뒤에 나오는 1초 오차도 이 축이 맞아야 나온다.
## 주입
### 5. 1차 회전 A → B 를 치고 시각을 담는다
**목적** — 옛 쿠키를 무효로 만들고, 그 시각을 정리 규칙의 기준으로 삼는다.
**행동** — 시각을 먼저 담고 참조를 바꾼다.
```bash label="[kc-lab-1] ① 회전 시각을 UTC 로 담는다"
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)"
```
```bash label="[kc-lab-1] ② secret 참조를 B 로 바꾼다"
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"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**예상 결과** — `successfully rolled out` 을 본다. 원래 실행의 1차 회전 시각은 이렇다(observed).
```text
11:29:56 UTC
```
**왜 필요한가** — `ROT` 이 정리 규칙의 절반이다. 안 담아 두면 나중에 어느 키가 회전보다 먼저 생겼는지 못 고른다. `env` 배열의 인덱스가 그 매니페스트와 맞는지는 B-7 에서 확인했고, 안 했으면 지금 `env[*].name` 을 본다.
**문제가 생기면** — 롤아웃이 끝나지 않으면 인덱스를 잘못 짚어 클라이언트 비밀을 덮었을 수 있다. 파드 로그를 본다.
## 주입 검증
### 6. Deployment 의 참조가 바뀌었는가
```bash label="[kc-lab-1] 지금 참조하는 키 이름을 뽑는다"
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
모양은 이렇다(모양은 observed).
```text
COOKIE_SECRET_B
```
### 7. Redis 는 그대로인가
주입 전과 똑같은 명령으로 본다.
```bash label="[kc-lab-1] 키와 TTL 을 다시 본다"
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
```
실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
[주입] 1차 회전 A → B — 11:29:56 UTC
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
```
키 수가 회전 전과 같다. 여기서 「실험 실패」라고 결론 내리면 틀린다. 회전은 방아쇠가 아니라 조건이고, 실제로 벌어지는 것은 누군가 옛 쿠키를 들고 오는 순간이다. A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 모양이다 — 주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다.
### 8. 브라우저로 다시 열고 그 순간의 로그를 본다
**무엇을 보는가** — 옛 쿠키를 들고 왔을 때 프록시가 무엇을 하는지.
로그인했던 그 브라우저 그대로 `https://app2.hyeonworks.com/api/echo` 를 연다.
```bash label="[kc-lab-1] 최근 2분의 세션 저장소 로그를 좁힌다"
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
```
**어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
브라우저가 접근한 순간(11:30:27) 로그:
[stored_session.go:94] Error loading cookied session:
session ticket cookie failed validation: <nil>, removing session
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
```
**이 값이 뜻하는 것** — `AuthSuccess` 의 시각 `11:30:27` 을 적어 둔다. 13 절에서 이 숫자와 역산값을 맞춰 본다. 그리고 Redis 를 다시 본다.
```bash label="[kc-lab-1] 키가 늘었는지 본다"
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
```
실측은 이렇다(observed).
```text
Redis:
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
dbsize=2
```
키가 둘이고 로그인 화면은 안 봤다. Keycloak SSO 가 살아 있어 조용히 재인증됐고 B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다.
## 관찰
### 9. Redis 값만 보고는 구분되지 않는 것을 열을 하나씩 지워 보인다
**무엇을 보는가** — 두 키의 네 가지 성질.
```bash label="[kc-lab-1] 두 키를 나란히 놓는다"
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K type=$(R type $K) len=$(R strlen $K) ttl=$(R ttl $K)"
done
```
**어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
[측정 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 | 이것뿐이다 |
**이 값이 뜻하는 것** — 값을 직접 봐도 소용없다. 암호화되어 있다.
아래 키 이름은 이 실험대에서 나온 값이라 그대로 치면 남의 키를 조회해 `(nil)` 이 돌아온다. 2 절의 `--scan` 출력에서 자기 키 이름을 옮겨 넣는다.
```bash label="[kc-lab-1] 값의 앞머리만 이스케이프해서 본다 — 키 이름은 자기 것으로"
R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo
```
세션 값은 암호화된 바이너리라 해시와 이스케이프된 앞머리만 옮긴다(observed).
```text
새 "\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` 로 이스케이프해서 보여 준다. 두 값이 다르다는 것은 알 수 있지만 어느 쪽이 고아인지는 말해 주지 않는다. 뜻을 읽을 수 없기 때문이다.
### 10. TTL 이 신호로 쓸 만한지 잰다
**무엇을 보는가** — 둘이다. TTL 이 실제로 줄어드는가, 요청을 보내면 되살아나는가. 30초 간격으로 세 번이고 여기에 1분이 그대로 든다.
```bash label="[kc-lab-1] 30초 간격으로 세 번 재는 루프 (unknown)"
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
```
**어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
30초 간격 3회:
t+00초 새=3557 고아=3479
t+30초 새=3526 고아=3448
t+60초 새=3494 고아=3417
```
**이 값이 뜻하는 것** — 30초에 30초씩 준다. 두 값의 차도 거의 고정이다 — 35573479 = 78, 35263448 = 78, 34943417 = 77. 차이가 1초 안에서 고정이라는 것은 둘 다 생성 시각에만 달렸다는 뜻이다. 그 1초의 흔들림은 TTL 이 초 단위 정수라서 생기는 반올림이고, 13 절의 1초 오차와 같은 것이다.
둘째 물음은 브라우저로 요청을 몇 번 보낸 뒤 다시 재서 확인한다(observed).
```text
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
살아있는 세션 ttl=3464 ← 계속 줄어든다
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
```
쓰고 있어도 TTL 이 안 늘어난다. 1 절에서 본 `refresh:disabled` 가 여기서 값으로 확인됐고, 따라서 고아는 생성 후 1시간에 사라진다. 무한정 쌓이지 않는다.
`--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이 연장된다. 끄면 생성 시점부터 고정된 시간이 흐른다. TTL 이 고정이면 이 식이 성립한다.
```text
생성시각 = 지금 - (cookie-expire - TTL)
```
이 한 줄이 정리 규칙 전체를 만든다. `cookie-expire` 는 1 절에서 `1h0m0s` = 3600 으로 확인했다. `--cookie-refresh` 를 켜는 순간 이 역산이 무너진다 — 활발히 쓰는 세션일수록 TTL 이 크게 남아 방금 만들어진 것처럼 보이고, 오래 안 쓴 산 세션은 TTL 이 작아 고아로 오판되어 지워진다. 그때는 회전 후 `_oauth2_proxy-*` 를 전부 지우고 모두 재인증시키는 편이 오히려 정직하다. **아래 정리 규칙은 `refresh:disabled` 일 때만 유효하다.**
### 11. 고아를 하나 지우고 산 세션이 멀쩡한지 본다
**목적** — 못 지우는 것이 프록시인지 Redis 인지 가른다.
**행동** — 되돌리기가 없는 조작이니 지우기 전에 어느 키인지 두 번 확인한다. 지금은 TTL 이 작은 쪽이 고아다.
```bash label="[kc-lab-1] ① 고아 하나를 지우고 남은 것을 센다"
R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
R dbsize
R --scan --pattern '_oauth2_proxy-*'
```
**예상 결과** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
dbsize 2 → 1
남은 키: _oauth2_proxy-87faa1c9…
```
반환값이 `1` 이다. `0` 이면 그 키가 없었던 것이고 이름을 잘못 옮겼다. 산 세션이 멀쩡한지는 브라우저로 다시 열어서 본다.
```bash label="[kc-lab-1] ② 삭제 직후 요청이 200 인지 로그로 본다"
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser
```
실측은 이렇다(observed).
```text
삭제 직후 브라우저 요청 (11:32:49):
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
```
같은 사실을 화면으로 찍은 것이 함께 있다(observed, `b7a-orphan-session__b7a-live-session-after-orphan-delete.png`).
**왜 필요한가** — 200 이 나왔으므로 산 세션은 영향이 없다. 「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다 — 프록시는 티켓을 못 풀어 키를 계산 못 하고, 운영자는 키를 직접 안다.
**문제가 생기면** — 반환값이 `0` 이면 키 이름을 `--scan` 출력에서 다시 옮긴다.
### 12. 2차 회전을 쳐서 일회성인지 누적인지 가른다
**목적** — 고아가 사건인지 회전의 고정 비용인지 가른다.
**행동** — 2차 회전 시각을 담고 B → A 로 되돌린다.
```bash label="[kc-lab-1] 2차 회전 시각을 담고 참조를 A 로 바꾼다"
ROT2=$(date -u +%s); echo "2차 회전 $ROT2 ($(date -u -d @$ROT2 +%H:%M:%S) UTC)"
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"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
그리고 브라우저로 다시 연 뒤 본다.
```bash label="[kc-lab-1] 키와 TTL 을 다시 본다"
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
```
**예상 결과** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
[측정 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…` 의 신분이 바뀌었다. 8 절에서 새 세션이던 것이 여기서는 고아다. 1차 회전을 살아남았던 세션이 2차 회전에서 고아가 됐다. 회전 1회에 그 시점 로그인 사용자 수만큼의 고아가 생기므로, 고아는 사건이 아니라 회전의 고정 비용이다.
**문제가 생기면** — 키가 안 늘면 브라우저로 접근하지 않은 것이다. 옛 쿠키를 들고 오는 요청이 있어야 벌어진다.
### 13. 규칙을 쓰기 전에 규칙 자체를 검증한다
**무엇을 보는가** — 역산값과 실측 한 쌍. 위 표의 생성시각(추정)은 역산식으로 나온 값이고, 대조할 실측은 8 절에서 적어 둔 `AuthSuccess` 시각이다.
**어디를 보나** — 실측은 이렇다(observed).
```text
[측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
**1초 오차.** 추정이 아니라 사실상 정확하다.
```
**이 값이 뜻하는 것** — 1초는 TTL 이 초 단위 정수라 반올림에서 나올 수 있는 크기다. TTL 역산은 추정이 아니라 측정에 가깝고, 그래서 다음 규칙을 안심하고 쓴다.
```text
생성시각 < 회전시각 → 그 키는 고아다
```
회전 이후에 만들어진 세션은 새 secret 으로 만들어졌으므로 반드시 유효하다. 회전 이전 생성분만 고른다.
## 복구와 원상복구 확인표
### 14. `del` 을 붙이기 전에 같은 루프를 `echo` 로 돌린다
**목적** — 무엇이 지워질지 먼저 읽는다.
**행동** — 판정만 하고 지우지 않는 루프를 한 번 돌린다.
**기준 시각을 먼저 정한다.** 이 루프가 재는 것은 「지금 걷어내려는 회전보다 앞에 만들어졌나」다. 5 절에서 담은 `ROT` 은 1차 회전 시각이고, 12 절을 쳤으면 지금 유효한 회전은 2차이므로 기준은 `ROT2` 다. **12 절을 친 뒤라면 아래 두 블록의 `"$ROT"` 를 `"$ROT2"` 로 바꿔 친다.** 안 바꾸면 두 회전 사이에 생긴 세션이 「산것」으로 분류되어 그대로 남고, 15 절의 실측(`87faa1c9…` 삭제 · 남은 `dbsize=1`)이 재현되지 않는다 — 그 키의 역산 생성시각 `11:30:26` 은 `ROT`(`11:29:56`)보다 뒤이기 때문이다.
```bash label="[kc-lab-1] 지우지 않고 판정만 하는 루프 — 12 절을 쳤으면 $ROT 을 $ROT2 로 (unknown)"
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` 를 루프 밖에서 한 번만 잡는다 — 안에서 잡으면 키마다 기준 시각이 달라진다.
**문제가 생기면** — 역산 생성시각이 미래거나 엉뚱하면 `--cookie-expire` 부터 확인한다. 9시간 어긋나면 `date` 에 `-u` 를 안 붙였다.
### 15. 확인한 뒤에 지운다
**목적** — 고아만 지우고 산 세션은 남긴다.
**행동** — 같은 루프에 `del` 을 붙인다. 14 절에서 `"$ROT2"` 로 바꿔 쳤으면 여기도 바꾼다. **두 블록은 연달아 친다** — 사이에 누가 로그인하면 리허설에서 못 본 키가 목록에 들어온다.
**리허설이 보장하는 범위.** 두 블록은 `NOW` 를 각각 새로 잡고 `C = NOW (EXP T)` 로 역산한다. `NOW` 와 `T` 가 같이 흐르므로 `C` 는 대체로 같은 값이 나오지만, 11 절이 적었듯 TTL 이 초 단위 정수라 ±1초가 반올림으로 흔들린다. **생성시각이 기준 회전 시각의 ±1초 안에 놓인 키는 리허설에서 「산것」이었다가 실행에서 「고아」로 뒤집힐 수 있고, 그쪽 방향의 오판이 곧 산 세션 삭제다.** 리허설 출력의 생성시각이 기준 시각에 붙어 있는 키가 보이면 그 키는 이 루프로 지우지 않는다.
```bash label="[kc-lab-1] 고아로 판정된 것만 지운다 — 14 절과 같은 기준으로 (unknown)"
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
```
**예상 결과** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`).
```text
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
산 세션은 남고 고아만 사라졌다.
```
**왜 필요한가** — 지운 뒤 브라우저로 한 번 더 열어 본다. 열리면 산 세션이 안 다쳤다. `dbsize` 는 이 Redis 전체를 센다 — BFF 세션과 B-5 가 남긴 키도 들어 있고, 여기서 `dbsize=1` 이 나온 것은 당시 다른 키가 없었기 때문이라 환경마다 다르다. 세션만 세려면 `--scan --pattern` 을 쓴다.
**문제가 생기면** — 전제가 깨졌을 때, 즉 `--cookie-refresh` 가 켜져 있을 때는 위 규칙을 쓰지 않는다. 전부 지우고 모두 재인증시킨다. `FLUSHDB` 를 쓰지 않는다 — 이 Redis 에는 BFF 세션도 들어 있어 패턴으로 좁히는 것이 이 실험대에서는 필수다.
```bash label="[kc-lab-1] 전제가 깨졌을 때 — 접두사에 걸린 것만 전부 지운다"
R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done
```
### 16. secret 참조와 Ingress 를 돌려준다
**목적** — 빌린 것을 원래대로 돌린다.
**행동** — 참조를 확인하고, Grafana Ingress 를 돌려준다.
```bash label="[kc-lab-1] ① 지금 참조가 A 인지 본다"
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
2차 회전에서 이미 A 로 돌아왔다면 그대로 둔다. B-7 에서 Grafana Ingress 를 빌렸다면 여기서 돌려준다. C-1 을 이어서 할 생각이면 아직 돌려주지 않고, C-1 이 끝난 뒤에 반드시 복구한다고 가이드가 적는다.
**돌려주기 전에 백업 파일이 손에 있는지 본다.** 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 없으면 다시 뜰 수도 없고, 되살리는 경로는 가이드에 없다(unknown). 그러니 없으면 아래 `delete` 를 치지 않는다.
```bash label="[kc-lab-1] ② 백업 파일이 쓸 만한지 본다"
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
```
줄 수가 나오고 둘째 줄이 `0` 이 아니면 그 파일로 돌려줄 수 있다. `No such file or directory` 면 여기서 멈춘다. B-4 로 app2 를 빌린 적이 있다면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽도 본다.
```bash label="[kc-lab-1] ③ 빌린 Ingress 를 걷고 백업을 올린 뒤 밖에서 본다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**예상 결과** — `app2` 응답이 B-7 을 시작하기 전 모양으로 돌아온다. 첫 줄의 상태 코드와 이어지는 `location` 헤더를 본다 — 여기서 갈라야 하는 것은 app2 가 아직 oauth2-proxy 로 가는지 Grafana 로 넘어갔는지다. Grafana 자체가 섰는지는 아래 표의 `get ingress grafana` 가 답한다.
**왜 필요한가** — 돌려주지 않으면 Grafana 가 안 열린다.
**문제가 생기면** — 참조가 B 로 남아 있으면 전제 절의 한 줄을 친다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
| 파드 | `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 | `kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
| 셸 변수 | `unset ROT ROT2 NOW EXP` | — |
`R` 은 셸 함수라 이 표의 `unset` 으로 안 걷힌다. 걷는 명령이 가이드에 없으므로(unknown), 같은 창으로 다른 실험을 이어 할 거면 창을 새로 연다. 안 그러면 `R` 이 계속 `keycloak-lab` 의 `redis` 를 가리킨다.
## 막히면
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 회전했는데 Redis 가 그대로 | 정상이다. 옛 쿠키를 들고 오는 요청이 있어야 생긴다 | 브라우저로 접근 |
| 고아와 산 세션이 구분이 안 간다 | 이름과 타입과 크기가 같다. 값은 암호화 | TTL 만이 신호다 |
| `get` 했더니 터미널이 깨진다 | 값이 바이너리다 | `redis-cli --no-raw get` |
| `ttl` 이 `-1` | 만료가 안 걸린 키다 | 이 절차의 대상이 아니다 |
| `ttl` 이 `-2` 이거나 `del` 이 `0` | 그 키가 없다 | 키 이름을 `--scan` 출력에서 다시 옮긴다 |
| 역산 생성시각이 미래거나 엉뚱하다 | `EXP` 가 3600 이 아니다 | `--cookie-expire` 를 확인 |
| 역산이 9시간 어긋난다 | `date` 를 로컬로 쳤다 | 전부 `date -u` |
| 산 세션이 고아로 잡힌다 | `--cookie-refresh` 가 켜져 있다 | 기동 로그의 `refresh:disabled` 확인 |
| 산 세션을 지워 버렸다 | 되돌릴 수 없다 | 재로그인한다. SSO 가 살아 있으면 조용히 지나간다 |
| `dbsize` 와 세션 수가 안 맞는다 | `b5:pvc` 와 BFF 세션이 섞인다 | `--scan --pattern '_oauth2_proxy-*'` |
| `FLUSHDB` 로 지웠더니 app1 도 끊겼다 | 같은 Redis 에 BFF 세션이 있다 | 패턴으로 좁혀 지운다 |
| 루프가 너무 느리다 | 키마다 `kubectl exec` 를 한다 | `--scan` 결과를 먼저 받아 두고 필요한 것만 묻는다 |
| 로그 시각이 9시간 어긋난다 | 프록시 로그는 UTC | 표시 규약 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 11:2911:34 UTC` 에 돈 한 번의 실행에서 나왔다(observed). 시각은 전부 UTC 로 다룬다 — 이 실험의 결론이 시각 계산이라 KST 와 섞이면 9시간이 틀어진다.
- (observed) 기동 로그의 `expiry:1h0m0s` 와 `refresh:disabled`, 회전 전 `_oauth2_proxy-f6a9201fd534a047998278452001ccbf` 의 `type=string` · `ttl=3568초` · `크기=3510바이트` · `dbsize=1`, 1차 회전 시각 `11:29:56 UTC` 와 회전 직후 키가 1개인 것, 브라우저 접근 시각 `11:30:27` 과 그때의 로그 네 줄, 새 세션 `87faa1c9…` `ttl=3588` 과 고아 `f6a9201f…` `ttl=3511`, 두 키의 `strlen` 이 둘 다 3510 인 것, 30초 간격 세 번의 TTL 여섯 값과 요청 후 `ttl=3464`, `del` 반환 `1` 과 `dbsize 2 → 1`, 삭제 직후 `/oauth2/userinfo` 의 `200 108`, 2차 회전 `11:33:27 UTC` 와 그 뒤의 `dad9c9fb… 3581` · `87faa1c9… 3373`, 역산한 `11:30:26` 과 로그의 `AuthSuccess 11:30:27` 이 1초 차인 것.
- 비밀은 길이와 존재만 적었다. cookie secret 은 Deployment 가 참조하는 키 이름(`COOKIE_SECRET_A` · `COOKIE_SECRET_B`)으로만 나오고 값은 어디에도 안 적었다. 세션 값은 암호화된 바이너리라 증거 파일이 남긴 md5 두 개와 이스케이프된 앞머리만 옮겼다. Redis 키 이름은 운영자가 `--scan` 으로 보는 식별자라 그대로 적었다.
- (unknown) `R()` 함수와 그것을 쓰는 `while` 루프들, 30초 간격 TTL 루프, 역산으로 고아를 고르는 `if` 루프. 가이드가 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다. 루프가 만든 값 자체(TTL 숫자들)는 증거 파일에 있으므로 실측이고, 그 값을 뽑아낸 형태가 미검증이다.
- 전제가 깨지면 성립하지 않는 것 — 역산 규칙은 `refresh:disabled` 에서만 유효하다. `--cookie-refresh` 를 켠 뒤에 어떻게 어긋나는지는 재지 않았다(unknown). 전부 지우는 대안을 쓴다는 것까지가 가이드가 적은 내용이다.
- 이 절차가 재지 않은 것 — 고아를 1시간 내내 두고 실제로 만료되는 것을 끝까지 지켜보지는 않았다. TTL 이 정직하게 줄고 갱신되지 않는다는 것까지가 측정이고, 「1시간에 사라진다」는 거기서 따라 나온 값이다. 산 세션을 잘못 지웠을 때의 피해도 재로그인 말고는 측정되지 않았다.
<!-- body:end -->
@@ -0,0 +1,717 @@
---
id: 3421185f-5f3c-4263-9455-6243306e9fc9
kind: SETUP
slug: reproduce-c1-multi-app-sso
title: 두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/3421185f-5f3c-4263-9455-6243306e9fc9/edit"
pinnedVersions:
- name: keycloak-pattern-bff
version: lab
- name: Redis
version: 7.4.x
source:
- final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1
- final/document.md#c층-재현-절차-두-편을-직접-치는-순서
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다
app1 과 app2 를 한 번의 로그인으로 묶은 뒤 IdP 세션만 끊고, 두 앱의 Redis 키가 글자 하나까지 같은지 터미널 출력으로 판정하는 절차다. 세션을 전부 지우고 시작하므로 실험대에서만 한다. 약 20분.
## 관계
- **백채널 로그아웃은 양쪽 다 없었다**
이 절차가 관측한 「로그아웃이 안 퍼진다」의 원인을 그 기록이 셋으로 나눠 판정한다. 여기서는 원인을 묻지 않고 현상까지만 친다.
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
Keycloak 세션이 캐시로 답한다는 것을 그쪽이 먼저 확인했다. 그래서 이 절차에서 `logout-all` 이 오류도 안 내고 세션도 안 줄인다.
- **세션과 인가된 클라이언트는 조회 키가 다르다**
같은 Redis 에 접두사가 다른 두 세션이 나란히 놓이는 것을 그 기록이 키 설계 쪽에서 설명한다.
- **IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다**
이 절차가 만든 상태 위에 선다. 이어서 할 생각이면 빌린 이름을 아직 돌려주지 않는다.
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
먼저 해 둬야 하는 편이다. 여기서 app1 로 쓰는 BFF 를 거기서 세운다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
먼저 해 둬야 하는 편이다. 여기서 app2 로 쓰는 oauth2-proxy 와 빌린 Ingress 를 거기서 만든다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. `kubectl``sudo` 를 붙이지 않는다. 반입한 가이드의 전제 문장은 `sudo kubectl` 로 적혀 있지만 같은 폴더의 README 가 반대로 적고, 본문 명령 블록에도 `sudo kubectl` 은 한 번도 없다. `sudo` 를 붙이면 root 환경으로 돌아 사용자 홈의 kubeconfig 를 못 본다.
`[밖에서]` 라벨이 붙은 `curl` 은 클러스터 밖에서 공개 이름을 두드린다는 뜻이다. 어느 기계에서 치라는 줄은 가이드에 없으므로(unknown), `https://app1.hyeonworks.com` 이 풀리는 기계면 어디서든 친다. 거기로 가는 `ssh` 명령도 가이드에 없다.
앱이 둘 필요하다. app1 은 BFF(Backend for Frontend, 브라우저 대신 토큰을 들고 있는 백엔드)이고 app2 는 oauth2-proxy 다.
브라우저도 필요하다. 인가 코드 흐름은 브라우저와 Keycloak 사이를 두 번 왕복하고, 두 번째 왕복에서 화면이 뜨는가 안 뜨는가가 이 절차의 관측 대상이다. `curl` 로는 「로그인 화면이 안 떴다」를 볼 수단이 없다. 브라우저 창 하나와 터미널 하나를 나란히 둔다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · Grafana 는 `observability` |
| 앱 | 둘. 둘 다 realm `keycloak-patterns` 를 본다 |
| 빌리는 이름 | `app2.hyeonworks.com` — 인증서가 세 이름만 덮어서 B-7 이 Grafana 에서 빌렸다 |
| 브라우저 | 같은 창의 새 탭으로 app2 를 연다. 시크릿 창은 SSO 쿠키가 없어 다른 결과가 나온다 |
| 주입 수단 | 브라우저로 app1 에 로그인하고 app2 를 방문한다 |
| 파괴 | 시작할 때 Keycloak 세션 테이블과 Redis 를 비우고 StatefulSet 을 재시작한다 |
| 도구 | `jq` 는 이 실험대에 없다. Keycloak 이미지에는 `curl``wget` 도 없다 |
| 전 구간 | 약 20분. Keycloak 재시작에만 1~2분 |
## 이 실험이 가르는 것
원래 질문은 한 줄이었다.
> *"SSO 를 추가하게 되면 어떻게 달라지는지"*
「달라진다」에는 방향이 둘 섞여 있다. 편해지는 쪽과 위험해지는 쪽이다. 위험 쪽의 통념은 이렇다.
| | 예측 |
|---|---|
| 통념 | SSO 를 붙이면 IdP 가 단일 장애점이 된다. IdP 가 죽으면 다 죽는다 |
| 실측 | 절반만 맞다. 로그인 **경로**는 그렇고, 이미 로그인한 사용자는 아니다 |
둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 갈린다. 수명이 세 층으로 나뉘어 있어서 그 셋을 따로 건드릴 수 있다.
```text
① IdP 세션 (Keycloak) ssoSessionIdleTimeout
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
③ access token 60초
①을 지워도 ②는 자기 수명을 산다
```
로그아웃이 지우는 것은 ① 뿐이다. 이 실험대에는 세션을 정반대로 다루는 앱이 둘 있어서 ②가 살아남는 모습을 두 형태로 동시에 볼 수 있다.
```text
app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL)
app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis)
둘 다 realm keycloak-patterns
```
가이드는 이것을 우연히 좋은 실험대라고 적는다. B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이 같은 IdP 를 쓰면서 세션을 다르게 다룬다.
절차를 끝까지 밟으면 여섯을 자기 화면에서 보게 된다. 두 번째 앱이 로그인 화면 없이 열리는 것, `user session 1``client session 2` 가 매달린 구조, 구조가 다른 두 앱이 같은 user session 을 공유하는 것, IdP 세션을 죽여도 두 앱이 열리는 것, `logout-all` 이 오류 없이 아무것도 안 하는 것, realm 을 안 보고 세면 `master` 의 admin 세션에 속는다.
## 전제와 되돌리기
- B-2 의 app1(BFF)과 B-7 의 app2(oauth2-proxy)가 둘 다 떠 있다. 없으면 SSO 가 아니라 로그인 한 번이다.
- `app2.hyeonworks.com` 은 Grafana 에서 빌린 이름이다. 이 실험이 끝나면 Ingress 를 돌려준다.
- 브라우저가 있어야 한다. `curl` 로는 「로그인 화면이 안 떴다」를 볼 수 없다.
- Keycloak 이미지에는 `curl``wget` 도 없다(`exit 127`). `kcadm.sh` 는 파드 안에 있으므로 항상 `kubectl exec` 로 감싼다.
- `jq` 는 이 실험대에 깔려 있지 않다.
- `~/grafana-ingress-backup.yaml` 이 손에 있어야 한다. 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 없으면 다시 뜰 수도 없고, 되살리는 절차는 가이드에 없다(unknown). B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽도 본다.
:::danger
이 실험은 세션을 전부 지우고 시작한다. 비교할 상태를 만들려고 Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고, Keycloak StatefulSet 을 재시작한다. 그 순간 지금 로그인해 있는 모든 사람이 끊긴다.
:::
되돌리기는 둘이고 둘 다 먼저 읽어 둔다. 하나는 세션을 다시 깨끗하게 만드는 것이라 주입 전 절차와 같은 명령이다.
```bash label="[kc-lab-1] ① 세션을 비우고 캐시를 버린다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
다른 하나는 빌린 이름을 돌려준다. ②는 먼저 지우고 나중에 올리므로, 백업 파일이 비어 있으면 Grafana 가 안 열리는 채로 끝난다. 실제로 칠 때는 복구 3 절의 확인 두 줄을 먼저 친다.
```bash label="[kc-lab-1] ② 빌린 Ingress 를 걷고 Grafana 것을 올린다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
```
:::warning
C-2 를 이어서 할 생각이면 아직 돌려주지 않는다. C-2 가 두 앱을 그대로 쓴다.
:::
지운 세션은 안 돌아온다. 이 실험의 파괴에는 되돌리기가 없고, 다시 로그인하는 것이 복구다.
## 주입 전에 같은 명령으로 먼저 본다
넓은 것부터 좁혀 간다. 이 층에서는 그 경로가 한 번 꺾인다. 세션을 지우려다 안 지워지는 것을 먼저 보고, 그다음에 세는 법을 고친다.
```text
앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다
```
### 1. 앱 둘과 Ingress 둘이 살아 있는가
**목적** — SSO 가 성립할 조건을 확인한다. 앱이 하나면 이 실험은 로그인 한 번이다.
**행동** — 클러스터 안을 먼저 보고, 밖에서 두 이름을 두드린다.
```bash label="[kc-lab-1] ① 파드와 Ingress 를 통째로 본다"
kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab get ingress
```
```bash label="[밖에서] ② 두 이름의 상태 코드만 뽑는다"
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/
```
**예상 결과** — ① 은 `bff` 와 `oauth2-proxy` 가 둘 다 `Running` 이고 Ingress 에 `app1.hyeonworks.com` 과 `app2.hyeonworks.com` 이 둘 다 있다. ② 의 실측은 이렇다(observed, `01-baseline.txt`).
```text
app1 HTTP 200 / app2 HTTP 200
```
**왜 필요한가** — 두 앱이 같은 realm 을 보고 있어야 client session 이 하나의 user session 아래 붙는다.
**문제가 생기면** — `app2` 가 Grafana 로 가면 B-7 의 Ingress 가 없다. 그쪽 백업과 적용을 먼저 한다.
### 2. kcadm 을 로그인시킨다
**목적** — 관리 API 를 칠 수 있게 한다.
**행동** — 관리자 비밀번호를 Secret 에서 읽어 명령 치환으로 넘긴다.
```bash label="[kc-lab-1] 비밀번호를 화면에 찍지 않고 로그인한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**예상 결과** — 오류 없이 끝나고 다음 `kcadm` 호출이 `401` 을 안 낸다.
**왜 필요한가** — 값이 명령 치환 안에서만 흐르므로 터미널에도 셸 히스토리에도 남지 않는다. 길이를 확인하는 명령은 B-0 절에 있다.
**문제가 생기면** — 아래에서 Keycloak 파드를 재시작하면 이 세션이 사라지고 이후 모든 `kcadm` 이 `401` 이 된다. 그때 이 명령을 다시 친다.
### 3. 가장 자연스러운 방법으로 세션을 지워 본다
**목적** — realm 전체 로그아웃이 실제로 무엇을 하는지 본다.
**행동** — 관리 API 를 치고 남은 세션을 센다.
```bash label="[kc-lab-1] ① realm 전체 로그아웃을 건다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create realms/keycloak-patterns/logout-all
```
이 실험대는 세는 쪽을 스크립트로 돌렸고 증거에 SQL 원문이 없다. 따라 하는 사람은 가이드가 손으로 치기 좋게 고친 아래 형태를 친다. 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[kc-lab-1] ② 남은 세션을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
```
**예상 결과** — 실측은 이렇다(observed, `01-baseline.txt`).
```text
=== 깨끗한 상태로 초기화 ===
DELETE 1
=== 기준선 ===
Keycloak 온라인 세션: 4
Redis 키: 0
```
`4` 다. 0 이 아니다. `logout-all` 이 오류도 안 내고 세션도 안 줄였다. A-1 에서 확인한 대로 Keycloak 은 세션을 DB 에서 읽되 캐시로 답한다. 관리 API 가 무효화를 걸어도 각 노드의 캐시가 안 바뀌면 세션은 살아 있는 것처럼 보인다.
**왜 필요한가** — 해설 문서의 이 값은 한 번 정정됐다. 처음에는 `0` 으로 인쇄됐는데 증거 `01-baseline.txt` 는 `4` 이고, `0` 은 다음 단계의 값이었다. 여기서는 4 가 나온다.
**문제가 생기면** — 다음 단계로 간다. 이 단계에서 0 을 만들려고 애쓰지 않는다.
### 4. DB 를 직접 지우고 캐시를 버린다
**목적** — `keycloak-patterns` 세션 수와 Redis 키를 둘 다 0 으로 만든다.
**행동** — 자식 테이블부터 지우고, 앱 세션을 비우고, 프로세스를 새로 띄운다.
```bash label="[kc-lab-1] ① 자식 테이블부터 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
```
모양은 이렇다(observed).
```text
DELETE 2
DELETE 4
```
```bash label="[kc-lab-1] ② 앱 세션도 비운다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
```
```bash label="[kc-lab-1] ③ 캐시를 버리려면 프로세스를 새로 띄운다"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
③ 이 끝나면 `keycloak-0` 파드가 새로 뜨고 그 안에 있던 kcadm 세션 파일이 함께 사라진다. 그러니 롤아웃이 끝나는 대로 주입 전 2 절의 `config credentials` 를 다시 친다. 건너뛰면 뒤에 나오는 `kcadm` 이 전부 `401` 을 내는데, 그 사실은 관찰 1 절에 가서야 보인다.
```bash label="[kc-lab-1] ④ 두 저장소를 다시 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**예상 결과** — 실측은 이렇다(observed, `01-baseline.txt`).
```text
Redis 키: 0
```
Redis 키 0, 세션 수 0 이어야 한다.
**왜 필요한가** — 캐시가 답하는 한 관리 API 로는 0 을 만들 수 없다. 행을 지우는 명령과 캐시를 버리는 명령이 따로 있다.
:::danger
`flushall` 은 이 Redis 전체를 지운다. BFF 세션과 oauth2-proxy 세션은 물론 B-5 가 남긴 `b5:pvc` 까지 전부다. 깨끗한 상태를 만드는 이 단계에서만 치고, 실험 도중에는 쓰지 않는다(B-7a 참고).
:::
**문제가 생기면** — 여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다. 그리고 재시작으로 `kcadm` 세션이 날아갔으므로 주입 전 2 절의 `config credentials` 를 다시 친다.
### 5. 세는 법을 고친다
**목적** — 숫자에 realm 을 붙인다.
**행동** — 두 형태를 나란히 친다. 이 실험대가 쓴 쪽이 틀린 방법이다.
```bash label="[kc-lab-1] ① 전체를 세는 쪽 — 틀린 방법이다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
```
따라 하는 사람은 `realm` 을 조인한다. 가이드가 미검증으로 표시한 형태다(unknown).
```bash label="[kc-lab-1] ② realm 을 조인하는 쪽"
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` 행은 4 절 뒤에 `config credentials` 를 다시 친 사람에게만 보인다 — 그 한 줄이 admin 세션을 새로 만든다. 4 절이 `master` 세션까지 지우고 재시작이 kcadm 세션을 날렸으므로, 아직 다시 안 쳤으면 아무 행도 안 나오고 그것도 맞는 상태다.
**왜 필요한가** — `offline_user_session` 에는 모든 realm 의 세션이 들어 있고, `kcadm` 을 쓰는 순간 `master` realm 에 admin 세션이 생긴다. 그냥 세면 내가 만든 잡음을 남의 세션으로 읽는다. 원래 실행은 이 한 열 때문에 「안 지워졌다」로 오독할 뻔했다. 여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다. 「세션 1개」가 아니라 「`keycloak-patterns` 세션 0개, `master` 1개」다.
**문제가 생기면** — 이 단계를 건너뛰면 관찰 절의 결론을 반대로 읽는다.
## 주입
주입은 브라우저로 한다. 두 앱에 차례로 들어가면 SSO 상태가 된다. 파괴적인 조작은 관찰 절에 있고 여기까지는 초기화를 다시 하면 되돌아온다.
### 1. app1 에 로그인한다
**목적** — 첫 로그인으로 IdP 세션을 만든다.
**행동** — 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 B-0 에서 만든 계정 `labuser` 로 로그인한다. 가이드는 이 줄에 계정 이름과 비밀번호를 나란히 적지만 여기에는 이름만 옮긴다. 그 비밀번호는 B-0 의 `set-password` 로 따라 하는 사람이 정하는 값이다.
**예상 결과** — Keycloak 로그인 화면이 뜨고 주소창이 이렇게 바뀐다(observed, 해설 문서에 남은 형태).
```text
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
?client_id=bff-confidential&...
→ Sign in to keycloak-patterns
```
**왜 필요한가** — 첫 앱에서는 로그인 화면이 나온다. 이것이 둘째 단계의 대조군이고, 이걸 안 보면 app2 에서 안 뜬 것이 특별한 일인지 알 수 없다.
**문제가 생기면** — 화면이 안 뜨면 이전 실험의 쿠키가 남아 있다. `auth.hyeonworks.com` 의 쿠키를 지우고 다시 연다.
### 2. 로그인 직후 상태를 잰다
**목적** — app2 를 방문하기 전의 대조값을 잡는다.
**행동** — 주입 전 5 절에서 고친 조인 쿼리에 realm 조건을 붙여 친다. 같은 이유로 미검증이다(unknown).
```bash label="[kc-lab-1] ① keycloak-patterns 세션만 본다"
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'"
```
실측은 이렇다(observed, `02-after-app1-login.txt`).
```text
=== app1 로그인 직후 Keycloak 세션 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 1
(1 row)
```
```bash label="[kc-lab-1] ② 앱 세션 쪽을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
실측은 이렇다(observed).
```text
Redis 키: 1
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
```
`authorized client` 를 세는 줄도 미검증이다(unknown).
```bash label="[kc-lab-1] ③ 토큰이 들어간 행을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from oauth2_authorized_client"
```
**예상 결과** — `client_sessions` 가 1 이고 Redis 키가 하나다. Redis 키 이름의 접두사 `bff:session:sessions:` 는 BFF 가 만든 세션이라는 뜻이고, 뒤에서 프록시 것과 갈라진다.
**왜 필요한가** — `user_session_id` 를 적어 둔다. 뒤에서 계속 쓴다. 한 번 로그인했는데 상태가 세 곳에 생겼다. Keycloak 세션, Redis 세션, PostgreSQL 토큰이고 관찰 절에서 이 셋의 운명이 갈린다.
**문제가 생기면** — 행이 0 이면 로그인이 아직 안 끝났다. 브라우저에서 app1 이 실제로 열렸는지 본다.
### 3. 같은 창의 새 탭에서 app2 를 연다
**목적** — SSO 상태를 만든다.
**행동** — 같은 브라우저의 새 탭에서 `https://app2.hyeonworks.com/api/echo` 를 연다.
**예상 결과** — 로그인 화면이 뜨지 않는다(observed, `c1-sso-app2-no-login-screen.png`). app2 는 Keycloak 으로 리다이렉트했지만 Keycloak 에 이미 세션이 있어서 묻지 않고 바로 돌려보냈다.
**왜 필요한가** — SSO 를 만드는 것은 `auth.hyeonworks.com` 에 붙은 브라우저 쿠키다.
**문제가 생기면** — 다른 브라우저나 시크릿 창에서 열면 안 된다. 창이 다르면 그 쿠키가 없어 로그인 화면이 뜬다.
## 주입 검증
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 여기서는 「두 번째 로그인」이 아니라 「같은 로그인에 앱이 하나 붙은 것」인지를 가른다.
### 1. 같은 user session 인가
```bash label="[kc-lab-1] 주입 전과 똑같은 줄을 친다 — user session 과 client session 수"
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'"
```
실측은 이렇다(observed, `03-after-app2-visit.txt`).
```text
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 2
(1 row)
```
`user_session_id` 가 앞과 같고 `client_sessions` 만 1 에서 2 로 늘었다. SSO 의 데이터 구조가 이렇게 생겼다.
```text
user session (사용자 · 브라우저 하나당 하나)
├─ client session : bff-confidential
└─ client session : oauth2-proxy
```
### 2. 어느 클라이언트가 붙었는가
조인해야 이름이 나온다. 가이드는 이 줄도 미검증으로 표시한다. 증거에 SQL 원문이 없고 출력만 있다(unknown).
아래 쿼리의 `oqOjHekin4JU-BZjgQLjUByW` 는 원래 실행의 세션 id 다. 치기 전에 주입 2 절에서 적어 둔 자기 값으로 갈아 끼운다. 그대로 치면 조건에 걸리는 행이 없어 `(0 rows)` 가 나오고, 그것을 「두 앱이 안 붙었다」로 읽으면 판정이 뒤집힌다.
```bash label="[kc-lab-1] 클라이언트 이름을 조인해서 본다"
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'"
```
실측은 이렇다(observed, `03-after-app2-visit.txt`).
```text
=== 어느 클라이언트가 붙었는가 ===
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 은 앱이 세션을 어떻게 다루는지 모른다.
`user_session_id` 는 따라 하는 사람의 환경에서 다르다. 2 절에서 적어 둔 값으로 바꿔 친다.
### 3. 저장소에는 무엇이 늘었는가
```bash label="[kc-lab-1] 주입 전과 똑같은 줄을 친다 — Redis 키 목록"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
실측은 이렇다(observed, `03-after-app2-visit.txt`).
```text
=== 저장소 상태 ===
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 에서 `FLUSHDB` 를 금지한 이유가 여기 있다.
### 4. 왜 두 층으로 나뉘어 있는가
Keycloak 은 세션을 `user session`(사람 하나)과 `client session`(그 사람이 쓰는 앱 하나)으로 나눠 둔다. A층과 B층에서 본 두 사건이 서로 다른 층을 건드렸다.
| | 무엇이 사라졌나 | 결과 |
|---|---|---|
| A-3 DB 크래시 | `user_session` 행이 통째로 | 모든 앱이 끊긴다 |
| B-3 refresh 재사용 탐지 | `client_session` 만 | 그 앱만 끊긴다 |
두 층이 나뉘어 있는 까닭이 SSO 다. 앱 하나의 사고가 다른 앱으로 번지지 않게 하려면 client session 이 따로 있어야 한다. 한 층뿐이었다면 B-3 의 재사용 탐지 한 번에 모든 앱이 끊긴다.
## 관찰
지우는 대상은 ①(IdP 세션) 하나다. ②(앱 세션)와 ③(토큰)은 손대지 않는다. 되돌리기는 다시 로그인하는 것이라 파괴적이지만 회복은 쉽다.
### 1. 세션 id 를 지목하는 방법은 안 먹는다
지우는 방법을 고르는 데서 하나가 걸러진다. 가이드가 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] 세션 id 로 지운다 — 안 먹는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns
```
이 id 는 이 실험대의 값이다. 위 출력에서 자기 세션 id 로 갈아 끼운다. 안 바꾸면 남의 세션을 지워 아무 일도 안 일어나는데, 이 절의 판정이 「오류도 안 나고 세션도 안 줄어든다」라 그 둘이 화면에서 구별되지 않는다.
오류도 안 나고 세션도 안 줄어든다. 앞의 `logout-all` 과 같은 유형이다.
### 2. 사용자 단위로 끊는다
**목적** — IdP 세션만 끊는다.
**행동** — 사용자 id 를 먼저 잡아 눈으로 확인하고, 그다음에 로그아웃을 건다.
```bash label="[kc-lab-1] 두 단계로 나눠 친다"
USERID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser --fields id \
--format csv --noquotes | tail -1)
echo "$USERID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create "users/$USERID/logout" -r keycloak-patterns
```
**예상 결과** — `echo "$USERID"` 가 UUID 한 줄이다.
**왜 필요한가** — 비어 있거나 여러 줄이면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고, 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다. 가이드가 자리표시자를 두지 않으려고 두 단계로 나눴다고 적는다. 한 줄로 이어 붙이면 `$USERID` 가 비었을 때 그 사실이 안 보인다.
**문제가 생기면** — 출력이 여러 줄이면 `--fields id` 가 다른 열을 함께 줬다. 먼저 `echo` 로 확인하고 다음 명령으로 넘어간다.
### 3. IdP 세션이 realm 별로 어떻게 남았는지 본다
```bash label="[kc-lab-1] realm 을 조인해서 센다"
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'"
```
실측은 이렇다(observed, `04-sso-session-killed.txt`).
```text
=== 사용자 단위 로그아웃 (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 세션이다.
:::warning
이 실험에서 가장 잘 틀리는 곳이 여기다. 「1이 남았네, 로그아웃이 안 먹었구나」로 읽으면 결론이 통째로 뒤집힌다. 숫자 옆에 realm 을 안 붙이면 그 숫자는 아무 뜻이 없다.
:::
### 4. 앱 세션을 주입 검증과 같은 명령으로 본다
```bash label="[kc-lab-1] 앞에서 친 것과 똑같은 줄이다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
실측은 이렇다(observed, `04-sso-session-killed.txt`).
```text
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
```
키 이름이 앞과 글자 하나까지 같다. 아무것도 안 지워졌다. 로그아웃은 ①만 지웠고 ②도 ③도 그대로다.
### 5. 브라우저로 두 앱을 다시 연다
아까 그 브라우저에서 `https://app1.hyeonworks.com/` 과 `https://app2.hyeonworks.com/api/echo` 를 연다. 둘 다 로그인 화면 없이 그대로 열린다(observed, `c1-apps-alive-after-idp-logout.png`).
:::warning
이 스크린샷과 app2 첫 방문 때의 스크린샷은 바이트 단위로 동일한 파일이다(md5 `2c703176…`). 두 시점의 화면이 실제로 같은 내용이었기 때문이고 조작은 아니지만, 그래서 두 시점을 구별하는 증거가 되지 못한다. 구별은 `03-after-app2-visit.txt` 와 `04-sso-session-killed.txt` 의 터미널 출력이 한다.
:::
화면이 같아 보인다는 것 자체가 이 실험의 결론이라 화면만으로는 증명이 안 된다. 판정은 `client_sessions` 가 1 에서 2 로 늘어난 출력과, IdP 세션을 지운 뒤에도 그대로인 Redis 키 두 줄이 한다.
### 6. 그러면 언제 끊기는가
앱은 매 요청마다 IdP 에 물어보지 않는다. 자기 세션이 살아 있으면 그걸로 답하고, 그래서 ①이 사라진 것을 모른다.
| | 언제 끊기는가 |
|---|---|
| BFF | access token 이 만료되어 refresh 를 시도할 때 → `Session not active` |
| oauth2-proxy | 쿠키 만료(1시간) 또는 토큰 갱신을 시도할 때 |
즉시가 아니라 지연되어 끊긴다. 최대 지연은 access token 수명(60초)이 아니라 앱이 다음에 IdP 를 부를 때까지다. B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다. 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고, 두 방향 모두 「한쪽만 지우면 다른 쪽이 안 지워진다」다.
실제로 끊기는 순간을 보려면 수명 두 값을 읽고 기다린다. 가이드는 이 줄을 미검증으로 표시했고 기다려서 확인하지도 않았다(unknown).
```bash label="[kc-lab-1] 수명 두 값을 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout
```
### 7. SSO 의 대가
| | 앱이 하나일 때 | SSO 일 때 |
|---|---|---|
| 로그인 | 앱마다 | 한 번 |
| IdP 가 죽으면 | 그 앱만 로그인 불가 | 모든 앱이 로그인 불가 |
| 이미 로그인한 사용자 | — | 영향 없다. 앱 세션이 살아 있다 |
| 로그아웃 | 그 앱만 | 전 앱을 끊으려면 백채널 로그아웃이 필요 |
| 세션 수명 | 하나 | 세 층이 각자. 어긋나면 예측이 어렵다 |
IdP 는 로그인 경로의 단일 장애점이지 이미 로그인한 사용자의 단일 장애점이 아니다. A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.
```text
Keycloak DB 죽음 → 새 로그인 불가 (전 앱)
→ 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
→ 그 뒤 갱신 시점에 한꺼번에 끊긴다
```
장애가 곧바로 전면에 드러나지 않고 앱 세션 수명만큼 늦게 몰려온다. 전 앱을 끊으려면 백채널 로그아웃이 필요하고, 그게 되는지는 C-2 가 잰다.
## 복구와 원상복구 확인표
### 1. 세션을 정리한다
**목적** — 다음 실험을 깨끗한 상태에서 시작한다.
**행동** — 주입 전 절차와 같은 명령이다.
```bash label="[kc-lab-1] 세션을 비우고 캐시를 버린다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**예상 결과** — 롤아웃이 끝나고 realm 조인 쿼리가 `keycloak-patterns` 0 을 준다.
**왜 필요한가** — 그냥 둬도 된다. 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고 IdP 세션은 이미 없다. 다음 실험을 깨끗하게 시작하려고 정리한다.
**문제가 생기면** — 재시작 뒤 `kcadm` 이 `401` 이면 `config credentials` 를 다시 친다.
### 2. 브라우저 쿠키를 지운다
`auth.hyeonworks.com` 과 `app1` 과 `app2` 의 쿠키를 지우거나 시크릿 창을 새로 연다. 서버 세션을 다 지워도 브라우저에 낡은 쿠키가 남고, 다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 그것이다.
### 3. 빌린 이름을 돌려준다
**목적** — Grafana 가 쓰던 `app2.hyeonworks.com` 을 원래 주인에게 돌린다.
**행동** — 빌린 Ingress 를 먼저 지우고 백업을 올린 뒤 밖에서 확인한다.
**지우기 전에 백업 파일이 쓸 만한지 본다.** 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 비어 있으면 다시 뜰 원본도 없고, 되살리는 절차는 가이드에 없다(unknown).
```bash label="[kc-lab-1] ⓪ 백업 파일이 쓸 만한지 본다"
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
```
줄 수가 나오고 둘째 줄이 `0` 이 아니면 그 파일로 돌려줄 수 있다. `No such file or directory` 가 나오면 아래 `delete` 를 치지 않는다. B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽을 본다.
```bash label="[kc-lab-1] C-2 를 이어서 하지 않을 때만 친다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**예상 결과** — `app2` 응답이 Grafana 로 돌아간다.
**왜 필요한가** — 인증서가 `auth` 와 `app1` 과 `app2` 세 이름만 덮어서 네 번째 이름을 만들 수 없었고, 그래서 B-7 이 Grafana 의 이름을 잠시 빌렸다. 돌려주지 않으면 실험이 끝나도 Grafana 가 안 열린다.
**문제가 생기면** — C-2 를 이어서 할 생각이면 이 단계를 건너뛰고 C-2 가 끝난 뒤에 친다. ⓪이 `No such file or directory` 를 냈다면 `delete` 를 치기 전이므로 Grafana 는 아직 살아 있다. B-7 의 백업 단계를 다시 읽고, 양쪽 경로에 다 없으면 Ingress 를 손대지 않은 채로 둔다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| Keycloak 세션 | realm 조인 쿼리 | `keycloak-patterns` 0. `master` 는 있을 수 있다 |
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었거나 남기기로 한 것만 |
| 토큰 | `select count(*) from oauth2_authorized_client` | 0 |
| 파드 | `kubectl -n keycloak-lab get pods` | 전부 `Running`, `keycloak` 둘 다 `1/1` |
| Ingress | `kubectl -n observability get ingress grafana` | 있다. 돌려줬다면 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
## 막히면
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `logout-all` 이 오류 없이 아무 일도 안 한다 | 캐시. DB 를 지워도 노드 캐시가 답한다 | DB 직접 삭제 후 `rollout restart` |
| `kcadm delete sessions/<id>` 가 조용히 안 먹는다 | 같은 유형 | `users/<id>/logout` 을 쓴다 |
| 기준 세션이 0 이 아니라 4 다 | 원래 실행도 4 였다. 해설의 `0` 은 정정됐다 | 주입 전 3 절의 정정 문단 |
| 로그아웃했는데 세션이 1 남았다 | `master` 의 admin 세션이다. `kcadm` 을 쳐서 생겼다 | realm 을 조인한다 |
| `kcadm` 이 전부 `401` | 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 |
| `$USERID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$USERID"` 로 먼저 확인 |
| app2 에서 로그인 화면이 뜬다 | 다른 브라우저나 시크릿 창이다. SSO 쿠키가 없다 | 같은 창의 새 탭에서 연다 |
| app2 가 Grafana 로 간다 | B-7 의 Ingress 가 없다 | B-7 의 백업과 적용을 먼저 |
| `client_id` 가 UUID 뿐이라 어느 앱인지 모른다 | `client` 테이블을 조인해야 이름이 나온다 | 주입 검증 2 절의 조인 쿼리 |
| Redis 를 비웠더니 app1 도 끊겼다 | `flushall` 은 BFF 세션도 지운다 | 깨끗한 상태를 만들 때만 쓴다 |
| 스크린샷 두 장이 똑같다 | 실제로 같은 파일이다. 조작이 아니다 | 구별은 터미널 출력이 한다 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 치거나 임시 curl 파드 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:4414:48 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 앱 둘의 `app1 HTTP 200 / app2 HTTP 200`, `logout-all` 뒤의 「Keycloak 온라인 세션: 4 · Redis 키: 0」, DB 삭제와 재시작 뒤의 「Redis 키: 0」, app1 로그인 직후의 `user_session_id` 와 `client_sessions 1`, app2 방문 뒤의 같은 id 와 `client_sessions 2`, 클라이언트 UUID 둘과 이름, Redis 키 두 줄, 사용자 단위 로그아웃 뒤의 「남은 세션 1」과 그 세션의 realm `master`, 그 뒤에도 Redis 키 두 줄이 글자 하나까지 같은 것.
- (observed) 브라우저 화면 둘. 하나는 app2 가 로그인 화면 없이 열렸고, 다른 하나는 IdP 세션을 지운 뒤에도 두 앱이 열렸다. 두 파일은 md5 `2c703176…` 로 동일하다. 그래서 두 시점을 구별하는 증거로는 못 쓰고, 구별은 터미널 출력이 한다.
- (unknown) realm 을 조인해 세션을 세는 쿼리, 클라이언트 이름을 조인하는 쿼리, `oauth2_authorized_client` 를 세는 줄, `kcadm delete sessions/<id>`, 수명 두 값을 읽는 `get realms … --fields`. 가이드가 전부 미검증으로 표시했다. 원래 실행은 스크립트로 돌렸고 증거에 SQL 원문이 없다.
- 비밀은 옮기지 않았다. 관리자 비밀번호는 명령 치환으로만 넘어가고 화면에 안 찍힌다. 브라우저 로그인 줄에서는 계정 이름 `labuser` 만 옮겼고 비밀번호는 안 옮겼다. 세션 id 와 Redis 키 이름과 클라이언트 UUID 는 식별자라 그대로 적었다.
- 버전은 이 편이 직접 잰 값이 아니다(inferred). C-1 출력에는 판 번호가 한 번도 안 찍혔고, C층은 B층 위에서 이어 돌았으므로 판을 물을 때는 같은 실험대의 B층 출력을 본다.
- 이 절차가 재지 않은 것 — 「언제 끊기는가」를 실제로 기다려서 확인하지 않았다. IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고 app1 을 새로고침하면 `Session not active` 가 나와야 한다는 것은 추론이고, 재려면 그렇게 한다고 가이드는 적는다.
<!-- body:end -->
@@ -0,0 +1,758 @@
---
id: 5296a106-4c42-437d-b721-33a5e53a045c
kind: SETUP
slug: reproduce-c2-backchannel-logout
title: IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다
topic: trust-handed-over-at-the-edge
topicName: 위조 신원 헤더와 로그아웃 전파
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5296a106-4c42-437d-b721-33a5e53a045c/edit"
pinnedVersions:
- name: curlimages/curl
version: 8.11.1
- name: keycloak-pattern-bff
version: lab
source:
- final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다
IdP 쪽에만 백채널 로그아웃 URL 을 넣고 로그아웃을 건 뒤, Redis 키 이름이 개수도 글자도 안 바뀌는지 확인하는 절차다. 받을 엔드포인트는 일부러 없는 채로 둔다. 약 20분.
## 관계
- **백채널 로그아웃은 양쪽 다 없었다**
이 절차가 판정한 후보 셋과 그 결론을 그 기록이 발견 쪽에서 적는다. 여기서는 명령과 출력만 친다.
- **쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션**
로그아웃이 안 퍼져서 남는 세션을 그쪽은 TTL 로 골라내 지운다. 여기서는 남는 데까지만 본다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
이 절차가 그 기준을 한 겹 더 앞으로 당긴 자국이다. 주입이 걸렸는지가 아니라 끊을 세션이 있는지부터 확인한다.
- **두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다**
이 절차의 전제다. 그쪽이 만든 두 앱과 빌린 이름을 그대로 쓴다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
app2 와 빌린 Grafana Ingress 를 거기서 만든다. 마지막에 돌려주는 것도 그쪽 백업 파일이다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. `kubectl``sudo` 를 붙이지 않는다. 앱 소스를 뒤지는 `grep -rn` 한 단계만 저장소를 체크아웃해 둔 워크스테이션에서 치고, 밖에서 후보 경로를 두드리는 `curl` 은 개발 머신에서 친다. 도달성을 재는 두 줄은 클러스터 안에 띄운 임시 파드 안에서 친다.
C-1 이 세운 두 앱을 그대로 쓴다. app1 은 BFF(Backend for Frontend, 브라우저 대신 토큰을 들고 있는 백엔드)이고 app2 는 oauth2-proxy 다.
브라우저도 필요하다. 인가 코드 흐름은 브라우저와 Keycloak 사이를 두 번 왕복하므로 `curl` 로 살아 있는 세션을 만들 수 없고, 이 절차는 끊을 세션이 있어야 성립한다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · Grafana 는 `observability` |
| 전제 | C-1 이 끝나 있다. 두 앱이 둘 다 살아 있다 |
| 주입 수단 | `bff-confidential` 클라이언트의 `attributes` 를 JSON 으로 통째로 교체한다 |
| 일부러 안 고치는 것 | 앱의 받을 엔드포인트. 후보 ①만 넣고 무슨 일이 나는지 본다 |
| 소스 트리 | `bff/src/main/java/` 를 볼 수 있어야 한다 |
| 도구 | `jq` 는 이 실험대에 없다. Keycloak 이미지에는 `curl``wget` 도 없다 |
| 임시 파드 | `curlimages/curl:8.11.1` · 이름 `c2probe` · `--rm` 으로 띄운다 |
| 전 구간 | 약 20분 |
## 이 실험이 가르는 것
C-1 이 관측한 것에서 출발한다.
```text
IdP 세션을 죽였다 → 앱 세션은 그대로 → 두 앱이 계속 열린다
```
C-1 은 왜 안 퍼졌는지를 안 물었다. 후보가 셋 있고 각각 판정하는 방법이 다르다.
| 후보 | 판정하는 법 |
|---|---|
| ① IdP 에 보낼 주소가 설정되어 있지 않다 | 클라이언트 속성을 본다 |
| ② 앱에 받을 엔드포인트가 없다 | 소스와 실제 경로를 본다 |
| ③ IdP 가 앱에 못 닿는다 (네트워크) | 클러스터 안에서 앱 URL 을 쳐 본다 |
| | |
|---|---|
| 예상 | 셋 중 하나가 원인일 것 |
| 실측 | ①과 ②가 둘 다 없었다. ③은 문제가 아니었다(`HTTP 200`) |
원인은 단순했다. 아무도 구현하지 않았다. 이 실험이 실제로 증명하는 것은 그다음이다.
```text
①만 고친다 → 여전히 안 퍼진다
```
양쪽이 다 있어야 동작한다. 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는 것이 이 주제에서 가장 흔한 실패이고, 이 절차는 그 실패를 일부러 재현한다.
:::warning
출처 하나에 주의가 붙어 있다. 해설 문서 2절이 인쇄한 「설정이 들어갔다」 확인 출력은 `02-configure-idp.txt` 에서 나오지 않았다. 그 파일에는 `command terminated with exit code 1` 이 남아 있다. 점 표기로 시도한 실패한 첫 시도다. 성공 출력은 그 뒤 별도로 실행한 조회에서 나왔다. 실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은 잘못이었고, 아래 주입 검증 절이 그 둘을 갈라 적는다.
:::
## 전제와 되돌리기
- C-1 이 끝나 있다. app1(BFF)과 app2(oauth2-proxy)가 둘 다 살아 있고 IdP 로그아웃이 앱에 전파되지 않는다를 이미 관측했다. 이 실험은 그 원인을 찾는다.
- `app2.hyeonworks.com` 은 Grafana 에서 빌린 이름이다. 끝나면 되돌린다.
- BFF 소스 트리(`bff/src/main/java/`)를 볼 수 있어야 한다.
- 브라우저가 필요하다. 살아 있는 세션을 만들어야 시험이 성립한다.
- Keycloak 이미지에는 `curl``wget` 도 없다(`exit 127`). 도달성 시험은 임시 curl 파드로 한다.
- `jq` 는 이 실험대에 깔려 있지 않다.
- `~/grafana-ingress-backup.yaml` 이 손에 있어야 한다. 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 없으면 다시 뜰 수도 없고, 되살리는 절차는 가이드에 없다(unknown). B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽도 본다.
:::danger
이 실험은 클라이언트 설정을 바꾼다. `bff-confidential` 클라이언트의 `attributes` 를 통째로 교체한다. JSON 으로 주는 방식이라 기존 속성이 같이 날아갈 수 있다. 그래서 주입 절의 첫 명령이 백업이다. 세션도 지운다.
:::
되돌리기는 백업한 값으로 다시 `update` 하는 것이고, 백업이 `{ }` 처럼 비어 있었다면 빈 객체로 되돌린다. 아래 한 줄이 그 빈 객체 갈래다 — 백업에 값이 있었던 사람이 그 값을 다시 넣는 명령은 가이드에 없다(unknown). 그쪽은 복구 1 절에서 다시 짚는다. `$CID` 는 주입 1 절에서 잡는 클라이언트 UUID 이므로, 여기를 먼저 읽는 지금은 아직 비어 있다. 실제로 칠 때는 그 절의 `CID=` 를 친 다음이다.
```bash label="[kc-lab-1] 클라이언트 속성을 되돌린다 — $CID 를 잡은 뒤에 친다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
```
그대로 둬도 무방하다고 가이드는 적는다. 받을 엔드포인트가 없으므로 이 설정 하나로는 아무 일도 안 일어나고, 그것이 이 실험의 결론이었다. 다만 나중에 앱을 고쳤을 때 왜 갑자기 동작하는지 모르게 되므로, 실험이 남긴 설정이라는 것을 기억하거나 지운다.
## 주입 전에 같은 명령으로 먼저 본다
넓은 것부터 좁혀 간다. 마지막 한 칸이 이 편에서 새로 붙었다.
```text
IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가
```
### 0. kcadm 을 로그인시킨다
**목적** — 관리 API 를 칠 수 있게 한다.
**행동** — 관리자 비밀번호를 Secret 에서 읽어 명령 치환으로 넘긴다.
```bash label="[kc-lab-1] 비밀번호를 화면에 찍지 않고 로그인한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**예상 결과** — 오류 없이 끝나고 다음 `kcadm` 호출이 `401` 을 안 낸다.
**왜 필요한가** — 값이 명령 치환 안에서만 흐르므로 터미널에도 셸 히스토리에도 남지 않는다.
**문제가 생기면** — Keycloak 파드가 재시작되면 이 세션이 날아가고 이후 모든 `kcadm` 이 `401` 이 된다. 그때 이 명령을 다시 친다.
### 1. IdP 쪽 클라이언트 속성을 통째로 본다
**목적** — 후보 ①을 판정한다.
**행동** — 두 클라이언트를 각각 본다.
```bash label="[kc-lab-1] 속성을 통째로 받아 눈으로 훑는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes
```
**예상 결과** — 실측은 이렇다(observed, `01-current-state.txt`).
```text
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
--- bff-confidential ---
"frontchannelLogout" : false,
--- oauth2-proxy ---
"frontchannelLogout" : false,
```
있는 것이 아니라 없는 것을 본다. `backchannel.logout.url` 이 목록에 없고 `frontchannelLogout` 하나만 나온다.
**왜 필요한가** — `grep backchannel` 로 걸러서 빈 출력을 보면 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다. B-6 에서 `kcadm get components -q type=…` 이 정확히 그렇게 조용히 실패했다. `--fields attributes` 로 통째로 받아 눈으로 훑고, 다른 값(`frontchannelLogout`)이 보이는 것을 「명령은 먹었다」의 증거로 쓴다.
**문제가 생기면** — 두 줄 다 아무것도 안 나오면 `kcadm` 이 `401` 이다. 0 절을 다시 친다.
### 2. 앱 소스를 본다
**목적** — 후보 ②를 소스 쪽에서 판정한다.
**행동** — 저장소를 체크아웃해 둔 곳에서 두 낱말을 찾는다. `bff/src/main/java/` 는 그 체크아웃의 루트에서 푸는 상대 경로다. 체크아웃을 어디에 뒀는지도, 워크스테이션으로 가는 `ssh` 명령도 가이드에 없으므로(unknown), 그 경로가 풀리는 디렉터리에서 친다.
```bash label="[워크스테이션] 소스에서 두 낱말을 찾는다"
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
```
**예상 결과** — 실측은 이렇다(observed, `01-current-state.txt`).
```text
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
```
아무것도 안 나온다. 헤더 아래가 비어 있다. Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 명시적으로 켜야 한다.
```java
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
```
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가 생기지 않는다.
**왜 필요한가** — 소스에 없으니 경로도 없다. 다만 빈 출력에는 원인이 둘이므로 다음 단계에서 배포된 앱을 직접 두드린다.
**문제가 생기면** — `grep` 이 빈 출력을 줄 때는 경로가 맞는지 먼저 의심한다. `ls bff/src/main/java/` 로 디렉터리가 실재하는지 본다. 없는 디렉터리를 뒤져도 `grep` 은 조용히 0건을 준다.
### 3. 배포된 앱의 실제 경로를 본다
**목적** — 소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이라 직접 친다.
**행동** — 먼저 응답을 통째로 한 번 보고, 그다음 후보 셋을 나란히 잰다.
```bash label="[밖에서] ① 응답을 통째로 본다"
curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12
```
상태줄과 `Location` 헤더를 본다. 302 라면 어디로 보내는가. 로그인 페이지로 보내면 인증이 필요한 요청으로 처리됐다는 뜻이고, 그런 핸들러가 없어서 기본 규칙에 걸렸다.
```bash label="[밖에서] ② 후보 셋의 상태 코드만 뽑는다"
for P in /logout/connect/back-channel/keycloak /backchannel-logout /oauth2/sign_out; do
curl -s -o /dev/null -w "$P %{http_code}\n" -X POST "https://app1.hyeonworks.com$P"
done
```
**예상 결과** — 실측은 이렇다(observed, `01-current-state.txt`).
```text
=== 실제로 그 경로가 있는가 ===
/logout/connect/back-channel/keycloak HTTP 302
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
```
| 응답 | 뜻 |
|---|---|
| `302` | 그런 핸들러가 없어서 인증 요구로 떨어졌다 |
| `200` / `400` | 엔드포인트가 있고 logout token 을 읽으려 했다 |
| `404` | 라우팅 자체가 없다 |
**왜 필요한가** — 302 는 「없다」의 증거다. 엔드포인트가 있었다면 `POST` 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다. 후보 ②가 확정됐고 ①은 앞에서 확정됐다.
**문제가 생기면** — 302 를 「있다」로 읽으면 판정이 뒤집힌다. 읽는 형태로 한 번 친 ①의 출력에서 `Location` 이 로그인 페이지를 가리키는지 확인한다.
### 4. 끊을 세션이 있기는 한가
**목적** — 주입할 대상이 있는지 확인한다. 이 칸이 이 편에서 새로 붙었고, 원래 실행이 여기서 한 번 헛돌았다.
**행동** — C-1 에서 배운 대로 realm 을 조인해서 센다.
```bash label="[kc-lab-1] ① realm 을 조인해 세고 Redis 도 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
where r.name='keycloak-patterns' and us.offline_flag='0'"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
원래 실행에서 나온 값은 이렇다(observed, `03-logout-attempt.txt`).
```text
=== 로그아웃 전 상태 ===
Redis: 2 키
keycloak-patterns 세션: 0
```
IdP 세션이 0 이다. Redis 에는 키가 2개 있는데 Keycloak 쪽은 비어 있다. 이 상태에서 로그아웃을 걸면 아무 일도 안 난다. 끊을 대상이 없기 때문이다. 그리고 「앱 세션이 안 지워졌다」를 보고 「전파가 안 되는구나」로 결론지을 뻔했다. 주입은 정상적으로 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. 틀린 것은 전제뿐이다.
**행동** — 그러니 세션을 만든다. 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 `labuser` 로 로그인한다. 가이드는 이 줄에 계정 이름과 비밀번호를 나란히 적지만 여기에는 이름만 옮긴다. 그 비밀번호는 B-0 의 `set-password` 로 따라 하는 사람이 정하는 값이다. 그리고 다시 센다.
```bash label="[kc-lab-1] ② 똑같은 두 줄을 다시 친다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
where r.name='keycloak-patterns' and us.offline_flag='0'"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**예상 결과** — 두 번째 시험의 실측은 이렇다(observed, `03-logout-attempt.txt`).
```text
=== 로그아웃 전 — 실제 세션이 있는가 ===
keycloak-patterns 세션: 1
Redis: 1 키
```
세션 수가 1 이상이어야 한다.
**왜 필요한가** — 여기서 0 이면 로그인이 안 된 것이고, 0 인 채로 주입 절로 넘어가면 결과가 무엇이 나와도 판정하지 못한다.
**문제가 생기면** — 0 이면 브라우저에서 app1 이 실제로 열렸는지 본다. 시크릿 창에서 열면 로그인 화면이 뜬다.
## 주입
의도적으로 한쪽만 고친다. 「①만 있으면 되는가」가 이 실험의 질문이다.
### 1. 지금 attributes 를 저장해 둔다
**목적** — 되돌릴 값을 파일로 만든다.
**행동** — 클라이언트 UUID 를 먼저 잡아 눈으로 확인하고, 지금 속성을 파일로 받는다.
```bash label="[kc-lab-1] UUID 를 잡고 속성을 파일로 받는다"
CID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields id \
--format csv --noquotes | tail -1)
echo "$CID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get "clients/$CID" -r keycloak-patterns --fields attributes \
| tee ~/c2-bff-attributes-backup.json
```
**예상 결과** — 실측은 이렇다(observed, `02-configure-idp.txt`).
```text
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
```
`echo "$CID"` 가 UUID 한 줄인지 본다. 이 UUID 는 C-1 에서 `bff-confidential` 로 확인한 바로 그 값이고, 따라 하는 사람의 환경에서는 다르다.
`tee` 는 파일에 쓰면서 같은 것을 화면에도 찍는다. 그 화면에 `attributes` 를 담은 JSON 대신 kcadm 의 오류 문구가 나오면 `$CID` 를 잘못 잡았고, 그 파일로는 되돌리지 못한다. 그때는 다음 절로 넘어가지 말고 `echo "$CID"` 부터 다시 본다 — 백업이 망가진 것을 다 끝난 뒤 복구 1 절에서 처음 알게 되면 그때는 이미 원래 속성이 날아간 뒤다.
**왜 필요한가** — `attributes=` 는 통째로 교체하므로 기존 속성이 같이 날아갈 수 있고, 되돌리기가 이 백업 파일에 달려 있다.
**문제가 생기면** — `$CID` 가 비었으면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고, 그 상태로 다음 명령을 치면 엉뚱한 클라이언트를 고친다.
### 2. 점 표기로 넣으면 죽는다
**목적** — 원래 실행이 처음 친 형태와 그 결과를 확인한다.
**행동** — 점 표기로 한 번 쳐 본다.
```bash label="[kc-lab-1] 점 표기 — 실패한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns \
-s "attributes.backchannel.logout.url=https://app1.hyeonworks.com/logout/connect/back-channel/keycloak"
```
**예상 결과** — 실측은 이렇다(observed, `02-configure-idp.txt`).
```text
command terminated with exit code 1
```
**왜 필요한가** — 종료코드 1 이다. 조용한 실패가 아니라 실패했다고 말해 준다. 다만 `kubectl exec` 를 거치면서 오류 본문이 잘려 「왜」는 안 보인다. 속성 이름 자체에 점이 들어 있어서(`backchannel.logout.url`) `kcadm` 의 점 표기와 충돌한다.
**문제가 생기면** — 다음 단계로 간다. 이 형태를 고쳐 쓰려고 애쓰지 않는다.
### 3. JSON 으로 통째로 준다
**목적** — 후보 ①만 넣는다.
**행동** — 두 속성을 한 JSON 으로 준다.
```bash label="[kc-lab-1] JSON 으로 통째로 교체한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
"backchannel.logout.session.required":"true"}'
```
**예상 결과** — 오류 없이 끝난다. 들어갔는지는 다음 절에서 따로 잰다.
**왜 필요한가** — 속성 이름에 점이 있어 점 표기로는 못 넣는다.
**문제가 생기면** — 이 실험대는 설정 JSON 을 명령줄에 직접 줬다(observed). 사람이 내용을 읽으면서 고쳐야 하는 값을 셸 한 줄에 담은 형태이고, 따라 하는 사람이 파일로 만들어 넣는 형태는 가이드에 없다(unknown). 없는 명령은 지어내지 않으므로 이 절차에도 없다.
## 주입 검증
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 이 편에서는 그 확인 자체에 출처 문제가 붙어 있다.
### 1. 두 속성이 둘 다 들어갔는가
```bash label="[kc-lab-1] 주입 전에 친 것과 똑같은 줄이다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
```
해설 문서 2절이 인쇄한 값은 이렇다(observed).
```text
backchannel.logout.session.required = true
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
```
두 속성이 둘 다 있는지 본다. `url` 만 있고 `session.required` 가 없으면 logout token 에 `sid` 가 안 실린다.
:::warning
이 출력은 `02-configure-idp.txt` 에 없다. 그 파일은 점 표기 실패로 끝나고, 위 값은 그 뒤 별도로 실행한 조회에서 나왔다. 증거 파일과 인쇄된 값이 하나씩 짝지어지지 않는 유일한 곳이므로 따라 하는 사람은 지금 직접 재 두는 편이 낫다고 가이드는 적는다.
:::
### 2. 이 시점의 Redis 키 이름을 적어 둔다
```bash label="[kc-lab-1] 키 이름을 그대로 적어 둔다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
실측은 이렇다(observed, `02-configure-idp.txt`).
```text
=== 로그인 상태를 만든다 ===
(브라우저에 이미 세션이 있다)
Keycloak 세션: 2
Redis: 2 키
```
위 실측의 「Keycloak 세션: 2 · Redis: 2 키」는 원래 실행의 첫 패스 값이다. 주입 전 4 절의 두 번째 시험까지 밟은 사람 화면에는 거기서 본 「세션: 1 · Redis: 1 키」가 그대로 나온다. 주입 1~3 절은 세션을 만들지 않으므로 두 숫자는 4 절에서 본 것과 같아야 하고, 다르면 그 사이에 브라우저가 한 번 더 로그인했다.
키 이름을 그대로 적어 둔다. 관찰 절에서 글자 하나까지 같은지를 본다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.
## 관찰
### 1. 시각을 적고 로그아웃한다
**목적** — IdP 세션을 끊는다.
**행동** — 시각을 남기고, 사용자 id 를 잡아 눈으로 확인하고, 로그아웃을 건다.
```bash label="[kc-lab-1] 시각을 남기고 사용자 단위로 끊는다"
date '+%H:%M:%S 로그아웃'
USERID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser --fields id \
--format csv --noquotes | tail -1)
echo "$USERID"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create "users/$USERID/logout" -r keycloak-patterns
```
**예상 결과** — 실측은 이렇다(observed, `03-logout-attempt.txt`).
```text
=== ★ IdP 로그아웃 → 백채널 알림 ===
시각: 14:54:21
```
**왜 필요한가** — 시각은 뒤에서 로그를 뒤질 때 「이 순간 전후」로 좁히려고 적는다. `--since` 만으로는 어느 시도인지 안 갈린다.
**문제가 생기면** — `$USERID` 가 비었으면 `echo` 로 먼저 확인하고 다음 명령으로 넘어간다.
### 2. IdP 세션이 끊겼는지 먼저 본다
```bash label="[kc-lab-1] realm 을 조인해 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
where r.name='keycloak-patterns' and us.offline_flag='0'"
```
실측은 이렇다(observed, `04-reachability.txt`).
```text
=== IdP 세션은 실제로 끊겼는가 ===
keycloak-patterns 세션: 0
```
0 이다. 로그아웃 자체는 동작했다. 이제 앱 쪽을 볼 자격이 생겼다. 여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 안 지워져 있어도 그건 당연한 결과라 아무것도 판정하지 못한다.
### 3. 앱 세션을 주입 검증과 같은 명령으로 본다
```bash label="[kc-lab-1] 앞에서 친 것과 똑같은 줄이다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
실측은 이렇다(observed, `03-logout-attempt.txt`).
```text
=== 앱 세션이 정리되었는가 ===
Redis: 2 키
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
```
세션이 실제로 1개 있던 두 번째 시험에서도 이렇다(observed, `03-logout-attempt.txt`).
```text
=== 앱 세션 ===
Redis: 1 키
```
개수도 이름도 그대로다. ①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과이고, IdP 쪽만 설정해도 소용없다.
### 4. 로그에 흔적이 있는지 본다
Keycloak 양쪽 노드와 앱 쪽이다.
```bash label="[kc-lab-1] ① Keycloak 두 노드를 센다"
kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel
```
실측은 이렇다(observed, `04-reachability.txt`).
```text
=== Keycloak 로그 전체에서 backchannel 흔적 ===
keycloak-0: 0 줄
keycloak-1: 0 줄
```
```bash label="[kc-lab-1] ② BFF 쪽 도착 흔적을 본다"
kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'
```
실측은 이렇다(observed, `03-logout-attempt.txt`).
```text
=== BFF 로그 — 백채널 요청이 도착했는가 ===
```
양쪽 다 비어 있다.
| 이 출력이 말하는 것 | 말하지 않는 것 |
|---|---|
| 로그에 `backchannel` 문자열이 없다 | Keycloak 이 요청을 안 보냈다 |
| BFF 로그에 도착 흔적이 없다 | 요청이 아예 안 왔다 |
:::danger
로그 레벨이 `DEBUG` 였다면 안 찍혔을 수 있다. 「0줄」은 「안 보냈다」의 증거가 아니라 「기본 로그 레벨에서는 안 보인다」일 뿐이다. 확실한 것은 앱 세션이 안 지워졌다는 관측이고 그것은 직접 봤다. 로그 0줄을 근거로 「Keycloak 이 안 보냈다」고 쓰면, 나중에 `DEBUG` 를 켜서 보냈다는 게 밝혀졌을 때 결론 전체의 신뢰가 무너진다.
:::
### 5. 임시 파드로 도달성을 잰다
**목적** — 후보 ③을 판정한다.
**행동** — Keycloak 파드에는 `curl` 이 없으므로 같은 네임스페이스에 임시 파드를 띄운다. 가이드가 미검증으로 표시한 줄이고 원래 실행의 명령 원문은 기록에 없다(unknown). 출력은 실측이다.
```bash label="[kc-lab-1] ① 임시 파드를 띄우고 들어간다"
kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 --command -- sh
```
파드 안에서 두 줄을 친다.
```sh label="[탐침 파드] ② 이름을 풀고 실제로 닿는지 본다"
nslookup app1.hyeonworks.com
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
```
**예상 결과** — 실측은 이렇다(observed, `04-reachability.txt`).
```text
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석:
Address: 100.83.212.4
Non-authoritative answer:
HTTPS 도달:
HTTP 200 (0 이면 못 닿음)
```
| 값 | 뜻 |
|---|---|
| `Address: 100.83.212.4` | 클러스터 안에서 공개 이름이 풀린다 |
| `HTTP 200` | 실제로 닿는다 |
| `HTTP 000` | curl 이 연결조차 못 했다 = 네트워크가 원인 |
후보 ③은 원인이 아니다. 네트워크는 열려 있고, 그래도 앱 세션은 안 지워졌다. 두 줄을 읽었으면 파드에서 나온다 — `--rm` 이 나가는 순간 파드를 지운다.
```sh label="[탐침 파드] ③ 파드에서 나온다"
exit
```
여기서부터 다시 `kc-lab-1` 이다.
**왜 필요한가** — 앱 세션이 안 지워지는 까닭이 「요청이 못 닿아서」일 수도 있고, 그러면 구현이 아니라 네트워크를 고쳐야 한다.
**문제가 생기면** — 임시 파드는 Keycloak 파드의 완전한 대역이 아니다. 같은 네임스페이스라 DNS 와 대체로 같은 경로를 타지만, NetworkPolicy 나 사이드카가 걸려 있으면 결과가 갈릴 수 있다. 이 실험대에는 그런 것이 없어서 대역이 성립했고, 확인은 `kubectl -n keycloak-lab get networkpolicy` 가 비어 있는지로 한다. 그 한 줄은 ③으로 나온 뒤 `kc-lab-1` 에서 친다 — `curlimages/curl` 이미지에는 `kubectl` 이 없어서 탐침 안에서 치면 못 찾는다고 끝난다.
:::warning
이 200 은 이 실험대의 특수 사정이다. tailnet 과 split DNS 구성이라 클러스터 안에서 공개 이름을 불러도 되돌아온다(헤어핀). 운영에서는 안 되는 경우가 흔하다. 앱이 사설망에 있고 IdP 가 밖에 있으면 설정을 해도 도달하지 못하고, 그때는 로그도 안 남고 조용히 실패한다.
:::
### 6. 그래서 왜 안 퍼졌나
세 단계로 정리된다.
```text
IdP 로그아웃
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (주입 절에서 설정함)
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
```
②와 ③이 없다. ①만 설정해도 받을 사람이 없다. 가이드는 「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서 확인한 것을 이 편의 값으로 적는다.
구조는 이렇게 생겼다.
```text
사용자가 어느 앱에서든 로그아웃
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
├──POST──▶ app1 의 backchannel.logout.url
└──POST──▶ app2 의 backchannel.logout.url
본문: logout_token (JWT)
{ "sid": "...", "sub": "...", "events": {...} }
```
`sid` 는 Keycloak 의 user session 식별자이고, A-0 에서 확인한 그 `sid` 다. JWT 와 DB 와 관리 API 에서 같은 문자열이었던 값이다. logout token 에 실려 오는 것이 `sid` 이고, 앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.
```text
logout_token 의 sid → 앱이 자기 세션 저장소에서 그 세션을 찾아 지운다
```
그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다. Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓴다. 엔드포인트가 있어도 그 역인덱스가 없으면 어느 세션을 지울지 모른다. 인스턴스가 여럿이면 그 레지스트리도 공유 저장소여야 한다. B-1 과 B-2 에서 겪은 것과 같은 문제가 한 겹 더 있고, BFF 는 replica 2개다.
부분 실패도 이 구조에서 나온다.
```text
app1 로그아웃 성공, app2 는 응답 없음
└─ Keycloak 은 재시도하는가? 얼마나?
└─ 사용자는 app2 에서 여전히 로그인 상태다
```
로그아웃은 원자적이지 않다. 앱이 늘어날수록 「일부만 로그아웃된 상태」가 생길 확률이 올라간다. 이 실험은 그 재시도 동작을 측정하지 않았다.
구현하려면 무엇이 필요한지도 가이드가 표로 적는다.
| 계층 | 할 일 | 이 실험대의 상태 |
|---|---|---|
| IdP | 클라이언트마다 `backchannel.logout.url` 설정 | 완료 |
| 앱 | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | 없음 |
| 앱 | `OidcSessionRegistry` 를 공유 저장소로 (인스턴스가 여럿) | 없음 |
| 네트워크 | IdP → 앱 공개 URL 도달 | 됨. 운영은 확인 필요 |
| oauth2-proxy | 지원하지 않는다. 별도 방안이 필요하다 | — |
마지막 줄이 C-1 과 맞물린다. app1(BFF)은 구현할 수 있지만 app2(oauth2-proxy)는 못 한다. 한 SSO 안에서 로그아웃 전파가 앱마다 다르게 동작하게 된다. C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데, 그 공유가 로그아웃까지는 안 간다.
## 복구와 원상복구 확인표
### 1. 클라이언트 속성을 되돌린다
**목적** — 실험이 남긴 설정을 지운다.
**행동** — 백업을 먼저 읽고, 읽은 값으로 되돌린다.
```bash label="[kc-lab-1] ① 백업에 무엇이 있었는지 본다"
cat ~/c2-bff-attributes-backup.json
```
원래 무엇이 있었는지 본다. 비어 있었으면 아래 ②가 그 갈래다.
**값이 있었으면 여기서 멈춘다.** 그 값을 다시 넣는 명령은 원본 가이드에 없다(unknown). 지어내지 않으므로 이 절차에도 없다. 주입 3 절의 `attributes=` 가 통째로 교체했으니 원래 속성은 ①이 찍어 낸 `~/c2-bff-attributes-backup.json` 안에만 있다. 그 파일을 지우지 않는다 — 되돌릴 명령을 구하면 그 파일이 있어야 쓸 수 있다. 그리고 ②를 치지 않는다. `attributes={}` 는 지금 들어 있는 것과 함께 원래 값까지 비운다.
```bash label="[kc-lab-1] ② 빈 객체로 되돌리는 경우 — 백업이 비어 있었을 때만 친다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
```
**예상 결과** — 주입 전에 친 `get clients … --fields attributes` 에서 `backchannel.logout.url` 이 사라진다.
**왜 필요한가** — 그대로 둬도 아무 일도 안 일어나지만, 나중에 앱을 고쳤을 때 왜 갑자기 동작하는지 모르게 된다.
**문제가 생기면** — `$CID` 가 셸에서 날아갔으면 주입 1 절의 `CID=` 를 다시 친다.
### 2. 세션을 정리한다
```bash label="[kc-lab-1] C-1 과 같은 네 줄이다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
브라우저 쿠키(`auth` 와 `app1` 과 `app2`)도 지우거나 시크릿 창을 새로 연다.
### 3. 빌린 이름을 돌려준다
**목적** — Grafana 가 쓰던 `app2.hyeonworks.com` 을 원래 주인에게 돌린다. C 층이 끝났으면 여기서 돌려준다.
**행동** — 빌린 Ingress 를 먼저 지우고 백업을 올린 뒤 밖에서 확인한다.
**지우기 전에 백업 파일이 쓸 만한지 본다.** 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 비어 있으면 다시 뜰 원본도 없고, 되살리는 절차는 가이드에 없다(unknown).
```bash label="[kc-lab-1] ⓪ 백업 파일이 쓸 만한지 본다"
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
```
줄 수가 나오고 둘째 줄이 `0` 이 아니면 그 파일로 돌려줄 수 있다. `No such file or directory` 가 나오면 아래 `delete` 를 치지 않는다. B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽을 본다.
```bash label="[kc-lab-1] ① 빌린 것을 걷고 백업을 올린다"
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**예상 결과** — `app2` 응답이 Grafana 로 돌아간다.
**왜 필요한가** — 인증서가 `auth` 와 `app1` 과 `app2` 세 이름만 덮어서 네 번째 이름을 만들 수 없었고, 그래서 B-7 이 Grafana 의 이름을 잠시 빌렸다. 돌려주지 않으면 실험이 끝나도 Grafana 가 안 열린다.
**문제가 생기면** — ⓪이 `No such file or directory` 를 냈다면 `delete` 를 치기 전이므로 Grafana Ingress 는 아직 없고 oauth2-proxy 것은 아직 있다. B-7 의 백업 단계를 다시 읽고, 두 경로에 다 없으면 Ingress 를 손대지 않은 채로 두고 여기서 멈춘다. oauth2-proxy 배포까지 걷어내려면 한 줄이 더 있다.
```bash label="[kc-lab-1] ② 배포까지 걷어낼 때만 친다"
kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
```
②의 `deploy/lab/k8s/b7-oauth2-proxy.yaml` 은 주입 전 2 절에서 `grep` 을 친 그 체크아웃 안에 있는 파일이다. `kc-lab-1` 에도 같은 체크아웃이 있는지는 가이드가 적지 않으므로(unknown), 그 경로가 풀리는 디렉터리에서 친다. `-n` 이 없으니 네임스페이스는 파일 안에 적힌 값을 따른다.
### 4. 임시 파드가 남았는지 본다
먼저 아래 확인표의 `kubectl -n keycloak-lab get pod c2probe` 로 남았는지 보고, `NotFound` 면 더 할 것이 없다. `--rm` 으로 안 지워졌으면 직접 지운다.
```bash label="[kc-lab-1] 남아 있으면 지운다"
kubectl -n keycloak-lab delete pod c2probe --ignore-not-found
```
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 클라이언트 속성 | `get clients … --fields attributes` | `backchannel.logout.url` 이 없다. 지웠다면 |
| Keycloak 세션 | realm 조인 카운트 | `0` |
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었다 |
| 임시 파드 | `kubectl -n keycloak-lab get pod c2probe` | `NotFound` 여야 정상 |
| 파드 | `kubectl -n keycloak-lab get pods` | 전부 `Running` |
| Ingress | `kubectl -n observability get ingress grafana` | 있다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
## 막히면
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 로그아웃했는데 아무 변화가 없다 | 로그아웃 전 세션이 이미 0 이었다 | realm 조인해서 먼저 센다 |
| `kcadm -s "attributes.backchannel.logout.url=…"` 이 `exit 1` | 점 표기가 안 먹는다 | JSON 으로 통째로 |
| JSON 으로 넣었더니 다른 속성이 사라졌다 | `attributes=` 는 통째로 교체한다 | 먼저 백업 |
| `$CID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$CID"` 로 먼저 확인 |
| 세션 수가 안 맞는다 | `master` 의 admin 세션이 섞인다 | realm 을 조인한다. C-1 과 같은 실수 |
| `grep -rn … bff/src/main/java/` 가 빈 출력 | 정말 없거나, 경로가 틀렸다 | `ls` 로 디렉터리 존재 확인 |
| 후보 경로가 `404` 가 아니라 `302` | 핸들러가 없어 인증 요구로 떨어졌다 | 302 도 「없다」의 신호다 |
| 로그의 `backchannel` 0줄을 근거로 삼고 싶다 | `DEBUG` 레벨이면 안 찍힌다 | 판정 근거로 쓰지 않는다 |
| 임시 파드에서 `HTTP 000` | 클러스터 안에서 공개 이름이 안 풀린다 | 운영에서는 그게 정상일 수 있다 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 임시 curl 파드 |
| `kcadm` 이 전부 `401` | 파드 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 |
| Keycloak 재시작 후 로그인 폼이 안 넘어간다 | 인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다 | 새 탭에서 주소부터 다시 연다 |
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | 빌린 이름을 돌려준다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:5014:53 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 두 클라이언트의 `attributes` 에 `frontchannelLogout` 만 있는 것, `grep -rn` 이 헤더 아래를 비워 둔 것, 후보 경로 셋이 전부 `HTTP 302` 인 것, 로그아웃 전 「Redis: 2 키 · keycloak-patterns 세션: 0」과 두 번째 시험의 「세션: 1 · Redis: 1 키」, 점 표기 시도의 `command terminated with exit code 1`, 클라이언트 UUID `9055fa46-6abb-4d6d-a339-8a9183bbf26d`, 로그아웃 시각 `14:54:21`, 로그아웃 뒤 `keycloak-patterns 세션: 0`, 그 뒤에도 Redis 키 두 줄이 같은 것, Keycloak 두 노드의 `backchannel` 0줄과 BFF 로그의 빈 출력, 임시 파드에서 본 `Address: 100.83.212.4` 와 `HTTP 200`.
- (observed·출처 주의) 설정이 들어간 것을 확인한 두 줄은 `02-configure-idp.txt` 에 없다. 그 파일은 점 표기 실패로 끝나고, 그 값은 뒤에 따로 실행한 조회에서 나왔다. 증거 파일과 인쇄된 값이 하나씩 짝지어지지 않는 유일한 곳이다.
- (unknown) 임시 curl 파드를 띄우는 `kubectl run c2probe …` 한 줄. 가이드가 미검증으로 표시했고 원래 실행의 명령 원문이 기록에 없다. 설정 JSON 을 파일로 만들어 넣는 형태도 가이드에 없다. 이 실험대는 명령줄에 직접 줬다.
- 로그 0줄로는 아무것도 단정하지 않았다. 「Keycloak 이 요청을 안 보냈다」는 이 출력으로 나오지 않는다. 기본 로그 레벨에서 안 보이는 것과 구별되지 않기 때문이고, `DEBUG` 를 켜서 다시 재지는 않았다(unknown).
- 비밀은 옮기지 않았다. 관리자 비밀번호는 명령 치환으로만 넘어가고 화면에 안 찍힌다. 브라우저 로그인 줄에서는 계정 이름 `labuser` 만 옮겼다. 클라이언트 UUID 와 Redis 키 이름은 식별자라 그대로 적었고, `backchannel.logout.url` 은 설정값이라 원문대로 적었다.
- 버전은 한 줄만 이 편이 직접 잰 값이다. `curlimages/curl:8.11.1` 은 이 편이 띄운 임시 파드의 출력이고(observed) 나머지는 B층 값이다(inferred).
- 이 실험대의 `HTTP 200` 은 구성 덕이다. tailnet 과 split DNS 라 클러스터 안에서 공개 이름이 되돌아온다(헤어핀). 운영에서 같은 값이 나온다고 볼 근거는 없다.
- 이 절차가 재지 않은 것 — ②와 ③을 실제로 구현한 뒤 전파가 되는지(코드를 고쳐야 한다), Keycloak 이 요청을 보내기는 했는지(`DEBUG` 로그를 안 켰다), 부분 실패 시의 재시도 정책.
<!-- body:end -->
@@ -0,0 +1,137 @@
---
kind: CASE
slug: commands-written-as-prose-do-not-run
title: 산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다
topic: when-the-measurement-lies
topicName: 주입이 걸렸는지 무엇으로 아는가
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#결정이-지켜지는지-확인하는-방법-재현-가능성
- final/document.md#검토한-선택지와-막힌-지점-스크립트를-쓰지-않는다
assets:
- key: reproducibility-gap
file: ../../../final/assets/reproducibility-gap/reproducibility-gap.svg
evidence:
- ../../../final/evidence/raw/a6-latency-injection__04-pool-under-load.txt
- ../../../final/evidence/raw/followup__05-command-reproducibility.txt
---
# 산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다
산문으로 적힌 측정 장치 넷을 셸 표현식으로 바꿔 실제로 돌렸더니 한 건이 깨졌다. 22.2초를 낸 A-6 의 부하 생성기였고, 일회성 파드의 stdout 이 유실돼 동시 20건 중 일부만 도착했다. 상주 탐침 안 파일로 모아 회수하는 방식으로 고치고 20/20 을 다시 확인했다.
## 관계
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
여기서 깨진 부하 생성기가 그 아홉 건의 마지막 항목이다. 고치고 나서야 아홉 번째 실패로 확정됐다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
주입을 확인하는 명령도 절차의 일부라서, 그 명령이 실제로 도는지까지 확인해야 이 기준이 성립한다.
## 문제
절차를 스크립트로 감싸면 무엇을 했는지가 스크립트 안으로 숨는다. 그래서 모든 절차를 셸에 그대로 붙여넣을 수 있는 명령으로 적었다.
명령을 적는 것만으로는 부족했다. 측정 장치 자체를 괄호와 설명으로 적어 둔 곳이 넷 있었고, 그 넷이 헤드라인 수치를 만든 명령이었다.
## 결론
산문으로 적혀 있던 측정 장치 : 4곳 (A-6 · A-3 · A-8 · A-1)
셸 표현식으로 바꾼 뒤 실제로 돌렸을 때 깨진 것 : 1건 (A-6 의 부하 생성기)
깨진 이유 : 일회성 파드의 stdout 이 유실된다
고친 방법 : 상주 탐침 + 파드 안 파일 수집
고친 뒤 : 동시 20건에서 20/20 도착
고친 장치에서 나온 값 : 가장 느린 요청 22.230871초 · 커넥션 획득 대기 최대 20000.0ms
명령을 실행 가능하게 고쳤다는 것과 고친 명령이 동작한다는 것은 다른 주장이다. 넷 중 셋은 고치자마자 돌았고 하나는 돌지 않았는데, 실제로 실행해 보기 전에는 넷이 같아 보였다.
## 검증 환경
대상 실험 : A-6 지연 주입 (200ms 를 걸고 동시 20건 로그인)
부하 대상 : keycloak-1
측정한 것 : 상태코드 · 응답 시간 · agroal 커넥션 풀 지표 · readiness 이벤트
처음 쓴 방법 : kubectl run --rm -i 로 일회성 파드를 띄워 stdout 수집
고친 방법 : 상주 탐침 안에서 실행하고 파드 안 파일로 모은 뒤 회수
스크립트 사용 : x
## 재현 조건
1. 절차 문서에서 괄호와 설명으로 적힌 명령을 찾는다.
실행할 수 없는 형태로 적힌 측정 장치를 골라낸다.
2. 그것을 셸에 붙여넣을 수 있는 표현식으로 바꾼다.
3. 바꾼 표현식을 실제로 실행한다. 문법 검사로 대신하지 않는다.
4. 기대한 만큼의 줄이 도착했는지 센다.
동시 20건이면 20줄이 있어야 한다.
5. 줄이 모자라면 출력 경로를 바꾼다.
일회성 파드의 stdout 대신 상주 탐침 안 파일에 쓰고 회수한다.
6. 고친 장치로 다시 측정해 원래 수치가 재현되는지 본다.
## 본문
<!-- body:start -->
## 스크립트를 쓰지 않기로 했다
절차를 스크립트로 감싸면 무엇을 했는지가 스크립트 안으로 숨는다. 파일 이름만 남고 그 안에서 어떤 명령이 어떤 순서로 돌았는지는 다른 파일을 열어야 알 수 있다. 그래서 이 실험대는 모든 절차를 셸에 그대로 붙여넣을 수 있는 명령으로 적었다.
그런데 나중에 재현 절차를 점검해 보니 셸에 붙여넣을 수 없는 형태로 남아 있는 곳이 넷 있었다.
## 명령으로 적히지 않은 곳이 넷 있었다
문서의 명령을 하나씩 봤더니 절차 대부분은 붙여넣으면 도는 형태였고, 걸린 넷은 전부 측정 장치 자체를 적어 둔 곳이었다.
| 어디 | 무엇이 산문으로 적혀 있었나 |
|---|---|
| A-6 | `( curl ... ) & 를 20개 띄우고 wait` ← 22.2초의 출처 |
| A-3 | `<로그인 반복, sid 를 /tmp/sids 에>` ← RPO 측정 전체 |
| A-8 | `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽는다 ← 빈 토큰을 보내고 있었다 |
| A-1 | conntrack 튜플을 손으로 적는다 ← 방향이 재시작마다 바뀐다 |
넷 다 이 실험대의 헤드라인 수치를 만든 명령이다. A-6 의 한 줄이 22.2초를 냈고, A-3 의 한 줄은 RPO(복구 시점 목표 — 장애로 잃을 수 있는 데이터의 시간 범위) 측정 전체를 냈다. A-8 은 적힌 대로 읽으면 토큰을 쓴 파일과 읽는 파일이 달라서 빈 토큰을 보내고 있었고, A-1 은 재시작마다 바뀌는 방향을 사람이 그때그때 적는 형태였다.
## 바꾼 다음 실제로 돌렸고, 거기서 한 건이 깨졌다
넷을 전부 셸 표현식으로 바꾸고 문법이 맞는지가 아니라 실제로 실행해서 확인했다. 돌려 보지 않고 「재현 가능하게 고쳤다」고 적는 것은 측정하지 않고 단언하는 일이라, 애초에 이 넷을 걸러 낸 점검이 잡아낸 실수를 그대로 되풀이하는 것이 된다. 그래서 실행 기록을 따로 남겼다. 셋은 그대로 돌았고 A-6 의 부하 생성기가 깨졌다.
깨진 이유는 표현식이 아니라 출력 경로였다. `kubectl run --rm -i` 로 띄운 일회성 파드는 명령이 끝나면 곧바로 지워지는데, 그 과정에서 stdout 이 유실돼 동시 20건의 결과가 일부만 도착하거나 아예 끊겼다. 두 번 시도해서 두 번 다 출력이 도착하지 않고 세션이 그대로 끊겼다. 셸은 오류를 내지 않고 종료 코드도 0 이므로, 도착한 줄 수를 세기 전에는 실패한 것으로 보이지 않는다.
같은 함정을 이 실험 시리즈에서 이미 한 번 겪었는데, 재현 절차를 실행 가능하게 고치면서 그 깨진 형태를 그대로 다시 써넣었다.
출력을 파드 밖으로 흘려보내는 대신 파드 안에 모으는 쪽으로 바꿨다. 상주 탐침 하나를 계속 띄워 두고 그 안에서 요청을 보내 결과를 파일에 쓴 뒤, 측정이 끝나고 파일을 통째로 회수했다.
![산문으로 적힌 측정 장치를 셸 표현식으로 바꾸고, 그것을 실행해 확인하는 단계까지 거치는 경로](../../../final/assets/reproducibility-gap/reproducibility-gap.svg)
두 화살표가 각각 다른 것을 걸러낸다. 첫 번째를 지나면 붙여넣어 실행할 수 있는 명령이 되고, 두 번째를 지나야 그 명령이 기대한 출력을 내는지 확인된다. 이 실험대에서 넷은 첫 번째를 다 지났고 한 건이 두 번째에서 멈췄다.
## 고친 장치로 다시 잰 값
바꾼 탐침으로 동시 20건을 다시 돌렸다. 20줄이 전부 도착했고 상태코드는 모두 200 이었다.
```text label="고친 부하 생성기로 다시 돌린 동시 20건과 직후의 커넥션 풀"
1 200 1.911191
1 200 19.053724
1 200 22.228466
1 200 22.230871
agroal_blocking_time_max_milliseconds 20000.0
agroal_max_used_count 19.0
agroal_acquire_count_total 672.0
```
가장 느린 요청이 22.230871초였고 이것이 A-6 이 보고한 22.2초다. 커넥션 획득 대기 최댓값은 20000.0ms 로 찍혔다. 일회성 파드로 재던 때에는 스무 줄 중 몇 줄이 도착했는지 셀 수 없었으므로, 같은 수치가 스무 건 전부에서 나온다는 것은 고친 장치로 돌린 다음에 확인됐다.
부하 직후에 함께 수집한 이벤트에는 `keycloak-1` 의 readiness 프로브가 503 과 타임아웃으로 실패한 기록이 남아 있다. 지연을 건 노드가 느려지다 로드밸런서에서 빠지는 구간이고, 응답 시간 20줄과 커넥션 풀 지표와 이 이벤트가 한 파일에 함께 들어 있다.
## 이 확인이 닿지 않은 곳
고친 명령을 다른 환경에서 돌려 보지 않았다. 이 실험대의 k3s 와 이 Keycloak 구성에서만 확인했고, 상주 탐침이 필요한 이유였던 일회성 파드의 출력 유실이 다른 런타임에서도 같은 모양으로 나타나는지는 재지 않았다.
넷 중 셋은 바꾼 표현식이 한 번에 돌았다는 것까지만 확인했다. A-3 과 A-8 과 A-1 의 측정값을 고친 명령으로 처음부터 다시 만들어 원래 수치와 맞춰 보지는 않았다.
실행 확인을 하면서 A-6 의 단일 요청 지연도 같이 쟀는데, 그것은 지연을 걸지 않은 평시 값이라 본문의 22.2초와 나란히 놓으면 안 된다. 거기서 확인한 것은 명령이 돈다는 것뿐이다.
<!-- body:end -->
@@ -0,0 +1,155 @@
---
kind: CASE
slug: nine-injections-that-silently-did-nothing
title: 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다
topic: when-the-measurement-lies
topicName: 주입이 걸렸는지 무엇으로 아는가
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다
assets:
- key: injection-verification
file: ../../../final/assets/injection-verification/injection-verification.svg
- key: a5-partition-asymmetry
file: ../../../final/assets/a5-partition-asymmetry/a5-partition-asymmetry.svg
evidence:
- ../../../final/evidence/raw/a1-jgroups-transport-block__05-conntrack-problem.txt
- ../../../final/evidence/raw/a6-latency-injection__03-flannel-injection.txt
---
# 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다
주입 명령이 아홉 번 걸리지 않았고, 걸리지 않은 주입은 관측에서 영향이 없는 것과 구별되지 않았다. 아홉 건의 원인은 네트워크·시그널·권한·출력 경로로 매번 달랐다. 공통점은 주입과 결과 관측 사이에 대상 상태를 보는 단계가 없었다는 것 하나였다.
## 관계
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
그 기준의 두 번째 규칙이 이 아홉 건에서 나왔다.
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
주입이 걸린 것을 확인한 다음에도 관측은 틀릴 수 있고, 그 다음 단계를 대조군이 맡는다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
주입이 제대로 걸린 A-2 에서도 이 지표는 1 이었다. 주입 확인과 지표 읽기는 서로를 대신하지 못한다.
## 문제
A층 여덟 실험은 예측을 먼저 적어 두고 주입한 뒤 관측하는 순서로 돌렸다. 주입 명령이 오류 없이 끝나면 걸린 것으로 보고 결과를 읽었다.
주입이 걸리지 않으면 관측에 아무 변화도 나타나지 않는다. 영향이 없어서 변화가 없는 경우와 같은 모습이므로, 이 실험대에서는 두 경우를 가를 방법이 없었다.
## 결론
조용히 실패한 주입 : 9건
원인이 겹치는 건 : 없음. 아홉 건이 각각 다른 이유로 걸리지 않았다
한 실험에서 세 번 실패한 곳 : A-5 비대칭 분단 (표의 4·5·6번)
공통 원인 : 주입과 관측 사이에 대상 상태를 보는 단계가 없었다
그 뒤 바꾼 것 : 주입한 다음 결과를 보기 전에 대상 상태를 따로 확인한다
상태를 보는 수단 : cluster_size · 워커 PID · conntrack 표 · 패킷 카운터
## 검증 환경
실험대 : 베어메탈 한 대 위에 VM 두 대
test-server : Arch Linux, 12GB, WiFi only
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
게스트 sudo : 무암호
호스트 sudo : 비밀번호 요구
주입에 쓴 것 : NetworkPolicy · iptables · tc · conntrack · kubectl delete · kill
## 재현 조건
1. 예측을 먼저 문서에 적는다. 무엇이 깨지고 무엇이 안 깨질지를 주입 전에 적어 둔다.
2. 주입 명령을 넣는다. NetworkPolicy 로 포트를 빼거나, iptables 규칙을 끼워 넣거나, tc 로 지연을 걸거나, 파드를 강제 종료한다.
3. 결과를 보기 전에 대상 상태를 조회한다.
conntrack 표에 그 포트의 ESTABLISHED 항목이 남아 있는지, iptables 규칙의 패킷 카운터가 0 인지, tc 필터에 패킷이 걸렸는지를 본다.
4. 상태가 바뀌지 않았으면 주입이 걸리지 않은 것으로 보고 결과를 읽지 않는다.
5. 주입 방법을 바꾸고 3번을 다시 한다.
## 본문
<!-- body:start -->
## 여덟 실험이 같은 모양으로 돌았다
A층 실험은 예측을 먼저 문서에 적어 두고 주입한 뒤 관측하고 마지막에 그 예측과 대조하는 순서였다. 주입은 상태를 일부러 망가뜨리는 명령이다 — NetworkPolicy 에서 포트를 빼 통신을 막고, `tc` 로 지연을 걸고, 파드를 강제 종료하고, `iptables` 규칙을 앞에 끼워 넣는다.
이 순서에는 확인이 하나 빠져 있었다. 주입 명령이 오류 없이 끝나도 대상이 실제로 그 상태가 됐는지는 보지 않고 바로 결과를 읽었기 때문에, 걸리지 않은 주입은 관측에서 「아무 일도 없었다」로 나타났다. 영향이 없어서 아무 일도 없는 것과 화면에서 같아 보인다. 이 실험대가 시간을 가장 많이 쓴 곳이 여기다.
## 아홉 건이 각각 다른 이유로 걸리지 않았다
| # | 무엇을 했나 | 왜 안 먹었나 |
|---|---|---|
| 1 | NetworkPolicy 로 7800 차단 | conntrack — ESTABLISHED 연결은 규칙 평가를 건너뛴다. `cluster_size` 가 25분간 2 로 남았다 |
| 2 | `kubectl delete --grace-period=0 --force` | 크래시가 아니다. 런타임이 SIGTERM 을 보내 PostgreSQL 이 정상 플러시했다 |
| 3 | `kill -9 1` | PID 1 은 자기 네임스페이스의 SIGKILL 을 무시한다 |
| 4 | `iptables -I FORWARD 1` | kube-router 가 자기 체인을 FORWARD 맨 위에 다시 끼워 넣는다 (패킷 0) |
| 5 | raw 규칙을 한쪽 노드에 | 방향이 뒤집혀 있었다. JGroups 의 client/server 역할은 재시작마다 바뀐다 |
| 6 | `tc ... dev eth0` | Debian 은 `enp1s0` 이고, flannel VXLAN 이 이미 캡슐화해 파드 IP 가 안 보인다 |
| 7 | `spring.sql.init` 로 스키마 생성 | 기본 DDL 이 `blob` 인데 PostgreSQL 은 `bytea` 다. `continue-on-error: true` 가 삼켰다 |
| 8 | 호스트에서 `sudo` | 비밀번호를 요구한다. 빈 출력이 곧 실패였다 |
| 9 | `kubectl run --rm -i` 로 동시 20건 | 일회성 파드의 stdout 이 유실된다. 20줄 중 일부만 도착하거나 아예 끊긴다 |
아홉 건을 원인으로 묶어 보려 했지만 묶이지 않았다. 네트워크 계층이 명령을 다르게 해석한 것도 있고, 종료 신호가 크래시가 아니었던 것도 있고, 오류를 삼키는 설정과 권한과 출력 경로도 하나씩 있었다. 아홉 건에 같은 대책을 걸 수 없었다.
## 게스트에서는 되고 호스트에서는 되지 않던 것
kc-lab-1 과 kc-lab-2 는 무암호 `sudo``conntrack`·`tc`·`iptables` 를 그대로 썼지만 호스트인 test-server 는 비밀번호를 요구한다. 이 차이를 모르고 한동안 호스트의 nginx 설정을 읽으려 했는데 계속 빈 출력이 돌아왔고, 그 빈 출력은 명령이 낸 답이 아니라 `sudo` 가 비밀번호를 받지 못해 멈춘 결과였다. 하마터면 빈 로그를 「아무 일도 없음」으로 읽을 뻔했다.
호스트에서 해야 하는 일은 인증서 강제 갱신과 nginx reload 인데 둘 다 결국 사람이 직접 쳐야 했으므로, D-4 에서는 명령 한 줄을 헛되이 쓰지 않는 것이 설계의 일부가 됐다.
## 한 실험에서 세 번 — A-5 비대칭 분단
표의 4·5·6번은 모두 A-5 한 실험에서 나왔다. 세 번 모두 다른 이유였고, 셋 다 화면에서는 「아무 일도 없었다」로 보였다.
A-5 가 물은 것은 한쪽 방향만 막았을 때 클러스터가 갈라지는가였다. 한 방향만 막으면 JGroups 가 열린 방향으로 재연결하므로 클러스터가 갈라지지 않는다. 양방향을 다 막으면 갈라지기는 하는데 한쪽만 DOWN 이 되어서, 코디네이터 쪽이 살아남고 분단된 쪽은 스스로 로드밸런서에서 빠지며 서비스는 이어진다.
![한 방향이 막혀도 반대 방향으로 연결이 성립하고, 양방향을 다 막아야 두 멤버가 분리되는 구성](../../../final/assets/a5-partition-asymmetry/a5-partition-asymmetry.svg)
그래서 이 실험에서는 주입이 걸리지 않은 것과 비대칭 차단이 원래 클러스터를 가르지 못하는 것이 같은 관측으로 나온다. 세 번의 실패를 하나씩 분리하려면 규칙이 실제로 패킷을 잡았는지부터 따로 봐야 했다.
## conntrack 표와 패킷 카운터를 직접 조회했다
1번에서 무엇이 걸리지 않았는지는 conntrack 표를 조회해서 확정했다. conntrack 은 리눅스 커널이 진행 중인 연결을 기억해 두는 표이고, 여기 등록된 흐름의 패킷은 방화벽 규칙을 다시 평가하지 않고 통과한다. NetworkPolicy 를 적용한 뒤 두 노드에서 7800 흐름을 뽑았다.
```text label="NetworkPolicy 적용 후에도 7800 연결이 conntrack 에 살아 있다"
--- 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
--- kc-lab-2 ---
tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] src=10.42.0.35 dst=10.42.1.58 sport=7800 dport=34824 mark=0 use=1
```
두 노드 모두 7800 으로 가는 ESTABLISHED 항목을 들고 있었다. 쿠버네티스가 넣어 둔 FORWARD 규칙은 전부 `NEW` 상태에만 걸려 있어서, 이미 성립한 연결의 패킷은 NetworkPolicy 평가에 도달하지도 않는다. 규칙이 안 걸린 것이 아니라 규칙은 정확히 걸렸고 패킷이 그 앞에서 지나갔다. `nf_conntrack_tcp_timeout_established` 는 86400 이고 오가는 패킷이 있으면 타이머가 계속 갱신되므로, JGroups 처럼 주기적으로 통신하는 연결은 사실상 영원히 표에 남는다. `cluster_size` 가 25분간 2 로 남은 것은 이상한 일이 아니라 정상 동작이었다.
항목을 지우는 것도 한 번에 되지 않았다. 두 노드에서 7800 흐름을 지웠는데 돌아온 것은 `0 flow entries have been deleted` 였고 삭제 뒤에도 항목은 두 건씩 남아 있었으며 `cluster_size` 도 2 그대로였다. 클러스터가 실제로 갈라진 것은 정책이 걸린 상태에서 파드를 재시작했을 때다. 처음 쓴 A-1 기록은 4초 전 파드 재시작이 만든 분단을 conntrack 공으로 돌렸고, 증거와 대조하면서 그 귀속을 고쳤다.
6번은 인터페이스를 바꿔 우회했다. Debian 게스트의 물리 인터페이스는 `enp1s0` 이고 flannel VXLAN 이 파드 트래픽을 이미 캡슐화하므로 거기서는 파드 IP 가 보이지 않는다. 캡슐화 전 구간인 `flannel.1` 에 같은 `netem` 을 걸고, 결과를 읽기 전에 필터에 패킷이 걸렸는지를 카운터로 확인했다.
```text label="flannel.1 에 건 netem 필터의 패킷 카운터와 두 노드 응답 시간"
=== [검증] 필터에 패킷이 걸리는가 ===
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0)
=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
keycloak-0 평균 41 ms 최대 57 ms
keycloak-1 평균 1872 ms 최대 1887 ms
```
카운터가 150 패킷으로 올라간 것을 보고 나서 응답 시간을 읽었다. 지연을 걸기 전 두 노드의 로그인 응답은 70ms 와 66ms 였는데, 주입한 지연이 200ms 인데도 `keycloak-1` 의 평균 응답은 1872ms 로 나왔다. 지연을 걸지 않은 `keycloak-0` 은 41ms 였다.
## 주입한 다음, 결과가 아니라 상태를 본다
아홉 건은 원인이 제각각이었고 공통점은 한 곳이었다. 주입 명령과 결과 관측 사이에 대상이 실제로 그 상태인지 보는 단계가 없었다. 그래서 그 단계를 넣고 이후 모든 실험에 적용했다. 확인하는 대상은 결과가 아니라 상태다 — `cluster_size`, 워커 PID, conntrack 표, 패킷 카운터.
![주입 명령에서 대상 상태 확인을 거쳐 결과 관측으로 가는 경로](../../../final/assets/injection-verification/injection-verification.svg)
가운데 단계를 건너뛰면 걸리지 않은 주입과 영향이 없는 주입을 결과만 보고 가를 수 없다.
## 이 아홉 건에서 확인하지 않은 것
아홉 건을 모두 고친 뒤 처음부터 다시 돌리지는 않았다. 일부는 원래 방법을 고치지 않고 다른 주입 방법으로 우회했다 — 6번은 `eth0` 대신 `flannel.1` 에 걸었고, 9번은 일회성 파드 대신 상주 탐침으로 바꿨다. 원래 방법이 왜 안 걸렸는지까지만 확정했고, 그 방법 자체를 동작하게 만든 것은 아니다.
<!-- body:end -->
@@ -0,0 +1,142 @@
---
kind: CASE
slug: seventy-six-failures-that-were-not-the-servers
title: 실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다
topic: when-the-measurement-lies
topicName: 주입이 걸렸는지 무엇으로 아는가
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는
assets:
- key: measurement-control
file: ../../../final/assets/measurement-control/measurement-control.svg
evidence:
- ../../../final/evidence/raw/d4-certificate-renewal__05-control-no-injection.txt
- ../../../final/evidence/raw/d4-certificate-renewal__11-inflight-full.txt
---
# 실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다
nginx reload 중 감시 로그에 찍힌 실패 76건은 서버에 닿지 않은 요청이었다. 연결수가 0 이고 소요 시간이 50µs 였다. 같은 순간 대조 폴링 49건은 전부 200 이었고, 같은 조건을 100번 반복해도 재현되지 않았다. 주입 전에 대조군 900건을 잡아 두지 않았다면 「갱신 중 대규모 요청 실패」로 적었다.
## 관계
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
그 기준의 세 번째 규칙을 이 확인이 실제로 써 본 결과다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
이 실험대에서 한 지표만 보고 판정한 다른 경우이고, 방향이 반대다. 그쪽은 이상을 못 봤고 이쪽은 없는 이상을 봤다.
- **두 시계에서 온 값을 빼지 않는다**
같은 D-4 에서 나온 다른 측정 결함이다. 감시를 돌린 기계와 실험대의 시계가 106초 달랐다.
## 문제
인증서 갱신 중 nginx reload 가 진행 중이던 요청을 끊는지 확인해야 했다. 새 연결만 폴링하면 TLS 핸드셰이크가 매번 새로 일어나서 「새 연결을 받아주는가」만 재게 되므로, reload 순간에 실제로 전송 중인 요청을 따로 만들어 감시했다.
그 감시 로그에 실패가 76건 남았다. 이 값만 보면 reload 가 진행 중 요청을 대량으로 끊은 것으로 읽힌다.
## 결론
감시 로그의 실패 : 76건
같은 순간 대조 폴링 : 49건 전부 200
실패한 요청의 연결수 : 0
실패한 요청의 소요 시간 : 50µs
같은 조건 재현 : 0/100
주입 전 대조군 : 900건 전부 200, 오류 0
서버 쪽 원인으로 귀속 : x
TCP 연결 시도조차 없었고 소요 시간이 DNS 조회보다 짧았으므로 요청이 서버에 닿지 않았다. 같은 순간 다른 경로로 들어간 요청은 전부 200 을 받았다. 훅을 넣고 다시 검증한 D-4a 에서 reload 자체는 새 연결 8856건 전부 200 이었고, 전송 12초째에 reload 를 맞은 42초짜리 요청은 845361바이트를 온전히 받았다.
## 검증 환경
실험 : D-4 인증서 갱신 중 nginx reload
호스트 TLS 종단 : nginx, Let's Encrypt 인증서
주입 전 대조군 : 0.2초 간격 900회 = 180초
대조군 응답시간 : 최소 67 중앙 98 p95 195 최대 1121 평균 106.9 (ms)
진행 중 요청 : 845KB 번들을 20k/s 로 받아 한 요청을 42초 동안 유지
관측 지점 : 외부 curl · Prometheus 지표 · PostgreSQL 직접 조회
시계 : test-server 는 NTP 가 꺼져 있어 106초 빠르다
## 재현 조건
1. 주입 전에 평시 오류율을 잰다.
0.2초 간격으로 900회 폴링하고 상태코드 분포와 응답시간, TLS 핸드셰이크 횟수를 기록한다.
2. reload 순간에 전송 중인 요청이 있도록 만든다.
845KB 번들을 20k/s 로 내려받아 요청 하나를 42초 동안 유지한다.
3. 새 연결 폴링과 진행 중 요청 감시를 각각 다른 프로세스로 띄우고 같은 시간대에 돌린다.
4. 인증서를 강제 갱신하고 nginx 를 reload 한다.
5. 실패가 나오면 그 순간의 대조 폴링 결과, 연결수, 소요 시간을 함께 읽는다.
6. 같은 조건을 100회 반복해 재현되는지 본다.
## 본문
<!-- body:start -->
## 주입 전에 평시를 먼저 쟀다
D-4 의 물음은 nginx reload 중 진행 중이던 요청이 어떻게 되는가였다. 주입에 쓸 강제 갱신은 진짜 인증서를 발급하는 명령이라 되돌릴 수 없고 Let's Encrypt 의 발급 한도(주당 중복 인증서 5장)를 한 장 깎는다. 그래서 이 실험 전체에서 한 번만 쓰기로 했고, 그 한 번을 헛되게 쓰지 않으려면 잴 것을 주입 전에 전부 재 둬야 했다. 답하기 전에 주입 없는 상태의 오류율부터 잡은 것이 그래서다 — 0.2초 간격으로 900회, 180초 동안 폴링해서 900건 전부 200 이고 오류가 0 이라는 것을 확인했다. 중앙값은 98ms, p95 는 195ms 였다.
이 폴링에는 한계가 하나 있었다. TLS 핸드셰이크가 900회 전부 일어나서 매 요청이 새 연결이었고, 그래서 이 폴링이 재는 것은 「새 연결을 받아주는가」다. 계획서가 물은 것은 진행 중이던 요청이므로 reload 순간에 실제로 전송 중인 요청이 있어야 했다. 845KB 짜리 번들을 20k/s 로 일부러 느리게 받아 요청 하나를 42초 동안 유지하는 감시를 따로 띄웠다.
## 76건이 한 초에 몰려 찍혔다
감시 로그는 146줄이었고 그중 76줄이 `코드=000` 이었다. 나머지 70줄은 `코드=200``바이트=845361` 이었다.
```text label="진행 중 요청 감시 — 76줄이 같은 초에 찍혔다"
08:14:22 코드=200 바이트=845361 시간=42.338482 연결수=1
08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0
08:15:04 코드=000 바이트=0 시간=0.000051 연결수=0
08:15:04 코드=000 바이트=0 시간=0.000056 연결수=0
08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1
```
76줄의 시각이 전부 `08:15:04` 로 같고, 같은 초의 다음 줄은 다시 `코드=200` 에 42.236496초다. 42초씩 걸리던 요청이 한 초 안에 76번 끝났다. 실패가 즉시 돌아오니 다음 요청을 바로 띄우는 감시 루프가 그 1초 동안 폭주했다.
## 76건을 서버 탓으로 적지 않은 근거 네 가지
76건을 reload 탓으로 적기 전에 네 가지를 봤다.
| 근거 | 값 |
|---|---|
| 같은 순간 폴링 | 49건 전부 200 |
| 연결수 | 0 — TCP 연결 시도조차 못 했다 |
| 소요 시간 | 50µs — DNS 조회보다 짧다 |
| 재현 | 0/100 |
연결수가 0 이므로 서버까지 패킷이 가지 않았다. 소요 시간 50µs 는 이름 풀이 한 번보다 짧아서 요청이 네트워크로 나간 시간이 아니다. 같은 순간 다른 프로세스가 보낸 49건은 전부 200 을 받았으므로 그 시각에 서버는 요청을 처리하고 있었다. 같은 조건으로 100번 더 돌려도 한 번도 다시 나오지 않았다.
## 대조군이 없었다면 무엇이 됐나
![주입 전 대조군과 대비될 때만 귀속이 성립하고, 대조군이 없으면 같은 관측이 오보로 이어지는 구성](../../../final/assets/measurement-control/measurement-control.svg)
같은 관측이 두 갈래로 갈린다. 평시 오류율을 아는 쪽에서는 76건을 대조군 900건과 견주어 서버 바깥 원인으로 돌릴 수 있고, 모르는 쪽에서는 「갱신 중 대규모 요청 실패」로 적게 된다.
이 실험대에서 대조군 규칙은 이미 두 번 어겨졌다. A-6 에서는 대조군이 −41% 인데도 「영향 없음」이라고 적었고, A-8 에서는 표본 9개로 무중단을 주장했다. 둘 다 나중에 고쳤다. 세 번째가 이 76건인데, 이번에는 대조군을 먼저 잡아 둔 덕분에 기록에 들어가기 전에 걸렸다.
## 같은 대조를 문서 전체에 돌렸다
기록을 다 쓴 뒤 본문의 주장과 증거 파일을 하나씩 맞춰 봤다. 어긋난 곳이 여섯 군데 나왔다.
| 어디 | 무엇이 어긋났나 |
|---|---|
| C-1 | 본문은 「세션 0」인데 증거는 4 |
| C-2 | `exit code 1` 인 명령의 성공 읽기를 실었다 |
| A-1 | 4초 전 파드 재시작이 만든 분단을 conntrack 공으로 돌렸다 |
| A-2 | 첫 측정의 `000000{"error":"HTTP 401"}401` 을 숨겼다 |
| A-3 | `wal_writer_delay` 를 재지 않고 단언했다 (실측 200ms, 로그인율도 19/s 가 아니라 14/s) |
| D-1 | 본문은 RTO 30초, 자기 타임라인은 41초 |
전부 고치면서 무엇이 어긋났는지를 표로 남겼다. 76건과 이 여섯 건은 같은 방향으로 틀린다 — 숫자 하나를 그 숫자가 나온 조건과 떼어 놓고 읽으면 본문이 자기 증거와 다른 말을 하게 된다.
## 이 확인이 말하지 않는 것
76건이 서버 탓이 아니라는 것까지가 이 확인의 범위다. 감시를 돌린 쪽에서 무엇이 그 한 초 동안 76번 즉시 실패했는지는 특정하지 못했다. 감시를 `-s` 로 돌려 curl 의 오류 메시지를 버렸고 종료 코드도 남기지 않았기 때문이고, 재현이 0/100 이라 같은 조건을 다시 만들어 좁힐 수도 없었다. 장치가 왜 실패했는지를 남기지 않은 탓에, 이 76건을 서버 밖으로 돌린 근거는 같은 시각 대조 폴링이 멀쩡했다는 사실 하나였다.
그래서 감시 자체를 바꿨다. curl 의 종료 코드까지 함께 적고 실패하면 1초 쉬어 루프 폭주를 막는 형태로 갈아끼웠고, 같은 로그의 뒷부분에는 그 뒤로 `curl종료=0` 이 줄마다 붙어 있다. 다음에 같은 일이 생기면 6이 이름 풀이, 7이 연결, 35가 TLS 라는 식으로 종료 코드가 바로 답한다.
<!-- body:end -->
@@ -0,0 +1,113 @@
---
kind: CASE
slug: the-guides-broke-at-the-first-command
title: 가이드 26편을 순서대로 따라가니 첫 명령부터 막혔다
topic: when-the-measurement-lies
topicName: 주입이 걸렸는지 무엇으로 아는가
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-11
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#재현-가이드-26편과-그것을-따라가다-드러난-결함
---
# 가이드 26편을 순서대로 따라가니 첫 명령부터 막혔다
기반 7단계를 끝낸 독자가 실험 가이드의 첫 명령에서 막혔다. 2026-09-11 에 가이드 26편을 순서대로 따라가 보니 sudo kubectl 905건 가운데 게스트에서 돌아야 하는 명령은 0건이었다. sudo 는 root 환경이라 lab host 의 ~/.kube/config 를 못 본다. 명령 자체가 아니라 그 명령이 놓인 위치가 틀렸다.
## 관계
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
가이드마다 주입 검증 단계를 따로 둔 근거가 이 아홉 건이다.
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
그 기록은 명령이 산문이라 돌지 않았고, 이 기록은 명령이 도는데 놓인 위치가 틀렸다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
가이드의 다섯 단계 가운데 주입 검증이 이 규칙을 절차로 옮긴 단계다.
## 문제
발견을 적은 문서와 별개로, 직접 쳐서 다시 만드는 가이드가 26편 있다. 각 편은 같은 다섯 단계로 적혀 있고 그중 주입 검증이 핵심인 편이 많다. 이 실험대에서 주입은 아홉 번 조용히 실패했고, 실패한 주입은 아무 일도 없었던 것처럼 보여서 영향이 없는 주입과 구별되지 않기 때문이다.
가이드를 쓴 다음 그것을 처음부터 순서대로 따라가 본 적은 없었다. 2026-09-11 에 실제로 쳐 보니 기반 7단계를 끝낸 독자가 실험 가이드의 첫 명령에서 막혔다.
## 결론
2026-09-11 감사에서 재현을 막는 결함이 계열로 나왔다.
sudo kubectl 을 쓰는 명령 : 905건
그중 게스트에서 도는 것 : 0건
저장소 클론 단계가 없는 편 : 05·06
그 단계에 없는 리소스를 조회하는 편 : 05
kubeconfig 는 lab host 의 ~/.kube/config 에 있고 sudo 는 root 환경이라 그 파일을 못 본다. 그래서 기반 7단계를 끝낸 독자가 첫 명령부터 막힌다. 905건 가운데 게스트에서 돌아야 해서 sudo 가 맞는 것은 0건이었고 전부 잘못된 것이었다.
개별 명령은 전부 실제로 돌았던 것이라 명령만 읽어서는 틀려 보이지 않는다. 틀린 것은 명령이 아니라 그 명령이 놓인 위치다.
## 검증 환경
감사 대상 : 재현 가이드 26편
가이드 위치 : ../source/docs/guides/experiments/
kubeconfig 위치 : lab host 의 ~/.kube/config
감사일 : 2026-09-11
## 재현 조건
1. 기반 가이드 7단계를 끝낸 상태에서 실험 가이드를 첫 편부터 순서대로 따라간다.
2. 각 편의 명령을 읽지 말고 실제로 쳐 보고, 막힌 곳과 막힌 이유를 적는다.
3. 26편에서 sudo kubectl 로 적힌 명령이 몇 건인지 세고, 그중 게스트에서 돌아야 하는 것이 몇 건인지 가른다.
4. 05·06 편의 kubectl apply -f deploy/... 가 가리키는 저장소가 lab host 에 있는지, 그 앞 단계에 클론이 있는지 확인한다.
5. 05 편의 -l app=bff 가 조회하는 리소스가 그 단계에 떠 있는지, 함께 실린 출력이 그 단계에서 나온 것인지 확인한다.
## 본문
<!-- body:start -->
실험 26건의 결과를 적은 문서와 별개로, 같은 실험을 직접 쳐서 다시 만드는 가이드가 26편 있다. 실험 26건을 다 끝낸 뒤 2026-09-11 에 그 26편을 처음부터 순서대로 따라가며 감사했다. 따라가는 동안 실험 계획서에 미해결로 남아 있던 항목 하나가 풀렸고, 재현을 막는 결함도 그때 나왔다 — 기반 가이드 7단계를 끝낸 독자가 실험 가이드의 첫 명령에서 막혔다. 막힌 곳은 네 군데였고, 한 편에서만 나온 실수가 아니라 계열로 나왔다.
## 가이드 한 편은 다섯 단계로 적혀 있다
각 편이 따르는 순서는 같다.
```text label="가이드 한 편의 구조"
기준선 → 주입 → 주입 검증 → 관찰 → 복구
```
다섯 단계 가운데 주입 검증이 핵심인 편이 많다. 이 실험대에서 주입은 아홉 번 조용히 실패했는데, 실패한 주입은 아무 일도 없었던 것처럼 보여서 영향이 없는 주입과 구별되지 않는다. 그래서 주입한 다음 그것이 실제로 걸렸는지 확인하는 단계를 따로 둔다.
## sudo 가 kubeconfig 를 못 본다
실험 가이드는 26편 전체가 `sudo kubectl` 로 적혀 있고, 세어 보니 905건이었다. 그런데 기반 가이드는 kubeconfig 를 lab host 의 `~/.kube/config` 에 두고, `sudo` 는 root 환경이라 그 파일을 못 본다. 그래서 기반 7단계를 끝내고 실험 가이드로 넘어온 독자는 첫 명령에서 바로 막힌다.
905건 가운데 게스트(lab host 위에서 도는 VM)에서 돌아야 해서 `sudo` 가 맞는 것은 0건이었다. 905건 전부 잘못 적힌 명령이었다. 이 905건과 0건은 2026-09-11 에 26편을 한 번 따라가며 센 값이고, 그 뒤 다시 세지 않았다.
## 05·06 편은 아직 없는 것을 가리킨다
05 편과 06 편은 `kubectl apply -f deploy/...` 를 쓴다. 이 경로는 상대경로인데 기반 가이드에는 저장소를 lab host 에 클론하는 단계가 없어서, 독자의 lab host 에는 그 경로가 가리킬 파일이 없다.
05 편에는 `-l app=bff` 로 리소스를 조회하는 명령도 있다. BFF(Backend For Frontend) 는 B-0 실험에서 처음 뜨므로 05 편 시점에는 조회할 리소스가 없고, 가이드에 실린 출력도 나중 단계에서 복사해 온 것이었다.
## 왜 명령만 읽어서는 틀린 곳이 안 보이나
막힌 네 곳을 나란히 놓으면 이렇다.
| 무엇이 재현을 막나 | 몇 건인가 |
|---|---|
| 실험 가이드 전편이 `sudo kubectl` 을 쓴다 | 905건 |
| 그중 게스트에서 도는 것 | 0건 |
| 기반 가이드가 저장소를 lab host 에 클론하지 않는다 | 05·06 |
| 그 단계에 아직 없는 리소스를 조회한다 | 05 |
네 결함의 원인은 하나로 모인다. 가이드를 쓰면서 순서대로 따라갔을 때 도는지를 검증하지 않고, 나중 시점의 환경에서 확인한 명령과 출력을 그 단계에 적었다.
개별 명령은 전부 실제로 돌았던 것이라 틀려 보이지 않는다. `-l app=bff` 의 출력도 지어낸 값이 아니라 나중 단계에서 실제로 나온 값을 옮겨 적은 것이었다. 틀린 것은 명령이 아니라 그 명령이 놓인 위치다.
## 이 감사가 확인하지 않은 것
고친 가이드를 처음부터 다시 따라가 끝까지 도는지는 확인하지 않았다. 905건과 0건도 2026-09-11 감사 한 번에서 나온 계수다.
이 감사의 결론을 「가이드를 쓴 뒤 처음부터 순서대로 한 번 따라간다」는 규칙으로 세우려면 어떤 가이드에 적용되고 어떤 가이드에는 적용되지 않는지를 대야 하는데, 그 적용 조건도 예외도 이 26편에서는 나오지 않았다.
<!-- body:end -->
@@ -0,0 +1,91 @@
---
kind: CONCEPT
slug: the-up-metric-cannot-see-alive-but-useless
title: up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다
topic: when-the-measurement-lies
topicName: 주입이 걸렸는지 무엇으로 아는가
project: keycloak-session-store
status: 게시 전
basisVersion: Prometheus up 지표 · Keycloak 26.7.0 /metrics · 이 실험대의 스크레이프 대상
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#검토한-선택지와-막힌-지점-관측을-어디에
- final/document.md#결정이-지켜지는지-확인하는-방법-관측-도구는-부분집합
evidence:
- ../../../final/evidence/raw/a2-database-loss__04-health-and-service.txt
- ../../../final/evidence/raw/a2-database-loss__05-recovery.txt
---
# up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다
A-2 에서 Keycloak 두 파드의 up 은 1 이었고 같은 실행의 외부 진입점은 503 이었다. up 은 Prometheus 가 /metrics 를 긁는 데 성공했는지만 말하므로, 프로세스가 살아서 그 엔드포인트를 돌려주면 DB 커넥션이 전부 끊겨도 1 이 된다. 이 어긋남을 이 실험대가 만난 것은 A-2 한 번이다.
## 관계
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
A-2 에서 readiness 는 두 파드를 다 DOWN 으로 보고했고 up 은 같은 순간에 둘 다 1 이었다. 두 신호가 같은 장애를 반대로 답했다.
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
지표 하나만 보고 귀속했을 때 반대 방향으로 틀린 경우다. 그쪽은 서버 탓이 아닌 실패를 서버 탓으로 셀 뻔했고, 이쪽은 서버가 못 쓰는 상태를 정상으로 셌다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
원본 가이드는 이 줄의 예측 칸을 비워 두고 「관측의 함정」이라고 적었다. 미리 적어 둔 예측이 빗나간 것이 아니라, 예측한 적 없이 튀어나온 관측이다.
## 본문
<!-- body:start -->
## Prometheus 가 스스로 붙이는 값
Prometheus 는 대상이 지표를 보내오기를 기다리지 않고 자기가 긁어 온다. 정해 둔 주소로 주기적으로 요청을 보내고 그 요청이 성공했는지를 스스로 만들어 붙이는 합성 지표가 `up` 이고, 대상이 응답하면 1 이고 못 하면 0 이 된다. 그래서 이 값이 답하는 물음은 하나로 좁다 — 방금 긁으러 간 대상이 지표를 돌려줬는가.
Keycloak 을 긁는 구성에서는 `/metrics` 엔드포인트 하나가 그 대상이다. 프로세스가 살아 있고 그 엔드포인트가 응답하기만 하면 1 이 되므로, 로그인이 되는지도 토큰이 발급되는지도 DB 커넥션이 살아 있는지도 이 값은 재지 않는다.
## A-2 에서 1 과 503 이 함께 나왔다
A-2 는 Keycloak 이 쓰는 PostgreSQL 을 정지시키고 무엇이 깨지는지 보는 실험이었다. DB 가 사라지자 두 파드의 Ready 는 모두 false 가 됐다. `health/ready` 는 네 항목 가운데 `Keycloak database connections async health check` 하나만 DOWN 인 채로 전체 DOWN 을 돌려줬고, Service 엔드포인트에서도 둘 다 notReady 로 빠졌다. 밖에서 `https://auth.hyeonworks.com/realms/master` 를 찍으면 `HTTP 503` 이었다.
같은 실행에서 Prometheus 에 `up` 을 물은 결과는 이랬다.
```text
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
```
파드는 재시작하지 않았다 — 복구까지 세어도 두 파드의 재시작 횟수가 0 이다. Keycloak 프로세스가 계속 떠서 `/metrics` 를 돌려주는 동안 `up` 은 1 을 유지했고, 그 1 은 커넥션 풀이 PostgreSQL 에 닿지 못한다는 것과 무관하다. Grafana 에서 `up{job="keycloak"}` 을 그려 보면 장애 구간이 평평하고, 그 그래프에 하나 있는 골은 A-1 에서 파드를 교체한 자국이다.
그래도 `up` 이 잡아 주는 것이 하나 있다. 대상이 사라지면 스크레이프가 실패해 0 이 되므로 Prometheus 가 대상을 잃은 것은 이 값으로 알 수 있고, 대상이 살아서 못 쓰는 상태만 1 과 구별되지 않는다. A-2 의 장애를 드러낸 신호는 파드의 `Ready` 가 false 인 것과 외부 응답 코드 503 이었다.
A-0 은 `up` 을 「가장 중요한 합성 지표」라고 적었고, A-2 의 가이드가 그 문장을 「절반만 맞다」고 정정했다. 맞는 절반이 대상이 사라지는 쪽이다. 노드의 전원을 뽑은 A-4 에서는 `kc-lab-2` 쪽이 전부 0 이었다 — `keycloak-0``node-exporter`, 그리고 노드마다 하나씩인 `kubelet` 두 줄 중 하나다. 가이드는 못 잡는 쪽이 운영에서 훨씬 흔하다고 덧붙였다.
## 이 실험대가 긁은 대상과 긁지 않은 대상
`up` 이 붙는 대상은 Prometheus 가 긁도록 설정해 둔 대상뿐이다. 이 실험대가 긁은 것은 keycloak·kubelet·node-exporter·prometheus 넷이고 Redis 와 BFF 와 PostgreSQL 은 대상에 없다. 그래서 B층 실험 대부분에 Grafana 스크린샷이 없다. 안 찍어서가 아니라 그 대상의 지표가 처음부터 없었기 때문이다. 이 실험대는 그것을 스크린샷 누락이 아니라 측정된 공백으로 적어 두었다.
지금 무엇이 대상인지는 Prometheus 에 직접 묻는다.
```bash
curl -s localhost:19090/api/v1/targets | jq -r '.data.activeTargets[].labels.job' | sort -u
```
## 기능 지표를 함께 보기로 한 이유
관측 지점을 여럿 둔 것은 A-2 보다 앞이다. 처음에는 밖에서만 쟀는데 A-1 에서 그 방식이 무너졌다 — 7800 을 끊었는데도 외부 응답이 전부 200 이었고, 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠졌기 때문이다. 그래서 외부 `curl` 과 Prometheus 지표와 PostgreSQL 직접 조회 셋으로 늘렸고, `up` 을 그대로 믿을 수 없다는 것도 같은 곳에서 나왔다.
`up == 0` 만 경보 조건으로 걸면 A-2 같은 장애는 경보를 만들지 않는다. 그 장애 동안 두 파드의 `up` 은 1 이었기 때문이다. 그래서 이 실험대는 `up` 과 함께 로그인 성공률이나 에러율 같은 기능 지표를 보기로 적어 두었다. 경보를 건다면 `up` 이 아니라 readiness 와 외부 응답 코드에 건다고도 적었다. 손으로 확인할 때는 두 값을 나란히 찍는다.
```bash
curl -s 'localhost:19090/api/v1/query?query=up' | jq '.data.result[].value'
# up 만 보지 말고 기능 지표를 함께 본다
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/realms/master
```
readiness 쪽은 이 실험대에서 지표로 물을 수 없었다. A-2 에서 `kube_pod_status_ready` 를 질의하자 결과가 빈 배열로 돌아왔는데, `kube-state-metrics` 가 없어 파드 readiness 가 지표로 남지 않기 때문이다. Prometheus 만 보고 있으면 이 장애는 드러나지 않는다. 관측 스택에 빠진 것을 이 실험이 찾아냈다. 가이드는 그것을 보완 항목으로 적고 지금은 `kubectl` 로 본다고 덧붙였다. B층의 관측 공백을 정리한 표에도 같은 항목이 「A-2 에서 이미 찾은 항목」으로 다시 적혀 있다.
## 지금 확인한 범위
`up` 이 1 인 채로 서비스가 503 이던 것을 이 실험대가 만난 것은 A-2 한 번이고, 「살아 있지만 쓸모없는 상태를 못 본다」는 그 한 번을 읽은 결론이다. 다른 장애 유형에서 같은 어긋남을 다시 본 적은 없다.
다만 두 값이 한 명령의 출력에 나란히 찍히지는 않았다. `up` 의 1 은 `a2-database-loss__05-recovery.txt` 에, 외부 `HTTP 503``a2-database-loss__04-health-and-service.txt` 에 있고, 두 캡처는 같은 실행에서 모은 것이라 실행 시각과 리비전이 같다. 원문에 붙은 「1 인데 서비스는 503 이다」도 도구가 찍은 줄이 아니라 실험을 돌린 사람이 그 줄에 덧붙인 주석이다.
`up` 이 1 이 되는 조건은 Prometheus 가 원래 그렇게 동작한다는 설명이고, 이 실험대가 스크레이프 요청과 `/metrics` 응답을 함께 찍어 그 조건을 확인한 캡처는 없다. 기능 지표를 함께 거는 경보를 실제로 만들어 A-2 를 다시 잡아 본 기록도 없다.
<!-- body:end -->
@@ -0,0 +1,103 @@
---
kind: REFERENCE
slug: never-subtract-values-from-two-clocks
title: 두 시계에서 온 값을 빼지 않는다
topic: when-the-measurement-lies
topicName: 주입이 걸렸는지 무엇으로 아는가
project: keycloak-session-store
status: 게시 전
source:
- final/document.md#결정이-지켜지는지-확인하는-방법-두-시계
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
evidence:
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
- ../../../final/evidence/raw/followup__03-b4-role-propagation.txt
---
# 두 시계에서 온 값을 빼지 않는다
서로 다른 기계에서 온 타임스탬프는 빼기 전에 두 시계가 같은지 확인한다. D-4a 에서 test-server 는 dev 머신보다 106초 빨랐다. 보정하지 않고 계산한 D-4 의 공백은 106초 짧았고(2199 → 2305초), 1~2초를 재는 D-4a 에서는 그냥 뺀 값이 107초로 나와 참값보다 약 106초 어긋났다.
## 관계
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
같은 D-4 의 값이다. 그 실험이 적어 둔 공백 2199초가 이 보정으로 2305초가 됐다.
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
측정값이 아니라 재는 쪽이 틀린 다른 경우다. 거기서는 적어 둔 명령이 실행되지 않았고, 여기서는 실행된 명령이 남긴 시각이 어긋나 있었다.
## 목적
보정하지 않고 뺀 값이 자릿수만 어긋난다고 읽는 것을 막는다.
D-4a 에서 갱신부터 서빙까지 1~2초를 재려다 걸렸다. test-server 는 NTP 동기가 꺼져 있고 dev 머신보다 106초 빠르다. 그 사실을 적지 않고 뺀 D-4 의 공백은 2199초였는데 보정하면 2305초, 38분 25초다. 틀린 값이 106초 짧게 나왔다.
같은 왜곡이 D-4a 에서는 결과를 통째로 바꾼다. 그냥 빼면 훅이 발급보다 107초 뒤로 보이는데 참값은 1~2초라 약 106초가 어긋나고, 보정을 반대쪽에 걸면 음수가 나와 훅이 갱신보다 먼저 돈 것이 된다. 자릿수만이 아니라 두 사건의 선후까지 뒤집힌다.
## 규칙
### 1. 빼기 전에 두 값이 같은 시계에서 왔는지 확인한다
D-4 의 2199초는 archive/cert2.pem 의 mtime 과 일련번호 관측 시각을 그대로 뺀 값이다. 앞은 test-server 시계이고 뒤는 dev 시계다. 양쪽 다 UTC 로 적혀 있어 뺄 때는 같은 자에서 읽은 것처럼 보인다. 갈리는 단위는 시간대가 아니라 기계다.
차를 셸이 대신 빼게 하지 않는다. 두 값을 각각 찍어 놓고 눈으로 빼면 어느 값이 어느 기계에서 왔는지가 출력에 남지만, 명령 안에서 빼 버리면 결과 한 줄만 남고 그 정보가 사라진다.
D-4 의 가이드에는 이 편에만 있는 시각 표기 규약이 하나 있다. 시각마다 어느 시계인지를 붙여 `08:58:52 (dev)``17:22:13 KST (ts)` 로 적고, 보정한 값은 `08:20:27 (실제)` 로 적는다. 두 기계의 시계를 섞어 빼는 바람에 숫자를 한 번 틀린 뒤에 붙은 규약이다.
### 2. 어느 시계가 밀렸는지는 제3의 기준으로 가른다
dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였고, test-server 는 Google 기준으로 -105초였다. 두 기계만 맞대면 차이만 나오고 어느 쪽이 맞는지는 안 나온다. test-server 의 timedatectl 은 NTP=no · NTPSynchronized=no 였다.
### 3. 왜곡값은 여러 번 재서 흔들리는지 본다
ssh 왕복으로 잰 왜곡은 3회 모두 +106.1초였다. 흔들리면 네트워크 지연이 섞인 것이고 안정적이면 왜곡이다. 이 실험대가 쓴 106초는 이 106.1초를 반올림한 값이다. Google 비교 쪽의 -105초와는 1초 넘게 갈렸다 — 둘 다 test-server 가 앞섰다는 것은 같지만, 보정에 넣은 것은 흔들림을 확인한 ssh 왕복 쪽이다.
### 4. 왜곡은 주입하기 전에 재 둔다
D-4 와 D-4a 의 재현 절차는 인증서를 강제 갱신하기 전에 시계부터 잰다. 주입이 끝난 뒤에는 그때 그 시계가 얼마나 어긋나 있었는지를 되짚을 방법이 없기 때문이다. D-4 는 시계를 재지 않고 2199초를 적었고, 나중에 D-4a 가 시계를 재면서 그 값이 2305초로 정정됐다.
강제 갱신은 진짜 인증서를 발급해 되돌릴 수 없으므로 이 실험 전체에서 한 번만 쓴다. 그 한 번을 헛되이 쓰지 않으려고 주입 전에 잴 것을 여덟 칸으로 적어 두었고, 시계는 그중 일곱 번째다.
관측도 한 기계에서만 한다. 이 실험은 전부 dev 머신에서 관측했고, 호스트에서만 알 수 있는 파일 mtime 과 훅 로그만 보정해서 썼다. 밖에서 본 것이 이 실험의 답이고, dev 가 이 실험대에서 유일하게 정확한 시계였기 때문이다.
### 5. 보정한 결과는 독립된 시계로 교차검증한다
새 인증서의 SCT 는 CT 로그가 자기 시계로 서명한 시각이라 test-server 것도 dev 머신 것도 아니다. 106초를 뺀 타임라인에서 훅의 nginx -t 는 발급 1초 뒤에 놓였다. 보정이 틀린 방향이었으면 훅이 발급보다 앞에 왔을 것이다.
다만 훅 시각은 초 단위로만 남은 로그 값이고 SCT 에는 밀리초가 붙어 있다. 이 실험대가 적은 「정확히 1초 앞」은 그 반올림을 거친 표현이라 차가 1.000초라는 뜻은 아니다.
인증서에 적힌 notBefore 는 발급 시각을 재는 기준으로 쓸 수 없다. Let's Encrypt 가 notBefore 를 정확히 한 시간 백데이트하므로 그대로 발급 시각으로 읽으면 한 시간을 잃고, 한 시간을 더한 값도 발급 시각이 아니다. 이 실험대의 두 인증서에서 SCT 는 그 값보다 약 89초 앞섰다.
### 6. 같은 왜곡이라도 다른 실험에서 잰 값을 섞어 쓰지 않는다
B-4 는 타임스탬프 둘의 차로 약 107초를 어림했고, D-4a 는 ACME 응답을 제3의 기준으로 두고 106초를 다시 쟀다. 둘 다 test-server 가 앞선 양이다. 보정에 쓸 값은 D-4a 의 106초이고, 107초는 B-4 의 12회를 읽기 위한 어림이다.
### 7. 음수 지연이 나오면 계산이 아니라 시계를 의심한다
D-4 와 D-4a 의 「막히면」 표는 공백이나 지연이 음수로 나오는 것을 시계 재는 절차로 돌아갈 신호로 적어 두었다. 선후가 뒤집힌 결과는 자릿수만 어긋난 값과 달리 바로 걸린다.
## 적용 조건
- 서로 다른 기계에서 온 타임스탬프의 차이를 잴 때
- 로그 시각과 외부 서비스가 찍은 시각을 견줄 때. D-4a 가 견준 것은 훅 로그와 CT 로그의 SCT 다
- NTP 동기를 확인하지 않은 호스트가 측정에 끼어 있을 때. test-server 가 그랬다
- 앞선 실험이 적어 둔 구간 값을 그대로 인용할 때. D-4 의 2199초가 D-4a 때문에 정정됐다
## 예외
- 같은 기계의 단조 시계로만 잰 구간에는 걸리지 않는다
- 재는 구간이 왜곡보다 훨씬 길면 값은 틀려도 결론은 남는다. D-4 에서는 오차가 106초여서 결론이 안 바뀌었고, 1~2초를 재는 D-4a 에서는 결과가 완전히 뒤집혔다
- 근거는 D-4·D-4a 한 쌍이다. B-4 에서도 같은 두 기계가 약 107초 어긋나 클레임 반영 시각이 변경 시각보다 앞서 보였는데, 그쪽은 어림으로 재고 넘어갔다. 이 실험대 밖의 측정으로 넓혀 확인한 적은 없다
## 예시
- test-server : NTP=no · NTPSynchronized=no · dev 머신보다 106초 빠름
- dev 머신 → Google : 0초 차. dev 머신 → Let's Encrypt ACME : 0초 차
- ssh 왕복 왜곡 3회 : +106.1 / +106.1 / +106.1초. 안정적
- test-server → Google : -105초. 같은 왜곡을 다른 방법으로 잰 값이다
- 보정식 : 실제 시각 = test-server 시계 106초. dev 시계는 보정하지 않는다
- D-4 의 공백 : 보정 전 2199초, 보정 후 2305초 = 38분 25초
- 보정하지 않은 D-4a : 그냥 빼면 107초, 참값 1~2초보다 약 106초 어긋난다. 보정을 반대쪽에 걸면 음수가 된다
- 보정한 D-4a 타임라인 : 발급 다음 초에 훅 nginx -t 와 새 워커 37252 기동, 그다음 초에 훅 nginx -s reload. 발급에서 서빙까지 1~2초
- SCT 는 밀리초까지 찍히고 훅 로그는 초 단위다. 「정확히 1초 앞」은 그 차이를 반올림한 표현이다
- D-4a 의 새 인증서 : notBefore 는 `Sep 4 11:29:18`, 한 시간을 더하면 `12:29:18`, SCT 는 `12:27:49.05` 다. 약 88.9초 앞선다
- B-4 의 약 107초 : 타임스탬프 둘의 차로 어림한 별개 값이다
@@ -0,0 +1,94 @@
---
kind: REFERENCE
slug: verify-the-injection-landed-separately-from-the-result
title: 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다
topic: when-the-measurement-lies
topicName: 주입이 걸렸는지 무엇으로 아는가
project: keycloak-session-store
status: 게시 전
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#결국-지키려던-것은-무엇이었나
- final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다
- final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는
evidence:
- ../../../final/evidence/raw/a1-jgroups-transport-block__07-cluster-size.txt
- ../../../final/evidence/raw/a6-latency-injection__03-flannel-injection.txt
- ../../../final/evidence/raw/a8-rolling-restart__01-restart-availability.txt
- ../../../final/evidence/raw/d4-certificate-renewal__05-control-no-injection.txt
- ../../../final/evidence/raw/d4-certificate-renewal__08-inflight-artifact.txt
---
# 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다
상태를 일부러 망가뜨리는 실험은 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다. 이 실험대가 위반 사례로 적은 둘은 전부 대조군 규칙이다 — A-6 의 −41% 대조군과 A-8 의 표본 9개다.
## 관계
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
두 번째 규칙이 이 아홉 건에서 나왔다. 아홉 건은 규칙을 세우게 만든 실패이고, 규칙을 세운 뒤의 위반 사례가 아니다.
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
세 번째 규칙을 지켜서 오보를 막은 가장 최근 실험이다. 대조군과 같은 순간의 폴링이 없었으면 측정 장치의 아티팩트가 서버 장애로 기록됐다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
A-2 에서 장애가 실제로 일어나는 동안 이 지표는 1 이었다. 주입이 걸린 것을 확인해도 관측 도구가 그 상태를 보여 주지 않는 경우가 남는다.
## 목적
주입 실험의 결과는 세 자리에서 틀릴 수 있다. 첫째, 무엇을 예상했는지를 결과를 본 뒤에 적으면 예측과 결과의 차이가 사라진다. 둘째, 주입이 걸리지 않으면 관측에 아무 변화도 나타나지 않아 영향이 없는 경우와 구별되지 않는다. 셋째, 평시 값을 모르면 변화가 주입 탓인지 다른 이유인지 가를 수 없다. 뒤의 두 자리에서 실제로 문제가 났다 — 주입은 아홉 번 조용히 실패했고, 대조군 규칙은 두 번 어겼다. 첫째 자리를 어긴 사례는 이 실험대의 기록에 없다.
그 기록은 세 번째가 가장 자주 어겨졌고 치른 값도 가장 컸다고 적는데, 그 문장은 세 규칙의 위반 횟수를 견준 수치가 아니라 기록을 쓴 사람의 판단이다. 숫자로는 세 번째 규칙을 어긴 사례 둘만 남아 있다.
## 규칙
### 1. 예측을 먼저 적는다
주입하기 전에 무엇이 일어날 것으로 보는지 적어 둔다. 결과를 보고 나면 무엇을 예상했는지 정직하게 쓸 수 없다.
이 실험대가 그 예측을 적어 둔 곳은 A-0 의 「9. 다음 실험에 대한 예측」이다. A-0 이 평시를 재고 난 직후, 아직 아무것도 주입하기 전에 쓴 표이고, 뒤따르는 절들의 「맞다 / 틀렸다」는 전부 그 표와의 대조다. 표 앞에는 「예측이 빗나가면 그것이야말로 배울 거리다」라고 적혀 있다.
표에 적힌 실험 번호는 옛 로드맵의 것이라 지금 번호와 어긋난다. 당시의 `B-5` 가 최종 B-3 이고 `A-3 노드 상실` 이 최종 A-4 인데, 번호를 고쳐 적지 않고 원문 그대로 두었다 — 예측을 언제 썼는지가 번호에 남아 있기 때문이다.
틀린 예측은 지우지 않고 남긴다. 이 실험대에서 빗나간 예측은 다섯이다. B-4 에서는 nginx 가 동명 헤더를 덮어쓸 것으로 봤지만 덮어쓰지 않았고, A-7 에서는 refresh 의 500 이 REVOKED_TOKEN 때문이라고 봤지만 CLIENT_SCOPE_CLIENT 였다. 그중 A-1 의 예측이 틀리지 않았다면 A-0 의 인과 설명이 잘못된 채로 남았을 것이다.
A-2 의 up 이 1 이던 것은 그 다섯에 들어가지 않는다. 원본 가이드가 그 줄의 예측 칸을 비워 두고 「관측의 함정」이라고 적었으므로, 미리 적어 둔 예측이 빗나간 것이 아니라 예측한 적 없이 튀어나온 관측이다.
이 규칙을 어긴 사례는 이 실험대의 기록에 적혀 있지 않다.
### 2. 주입이 걸렸는지를 결과와 따로 확인한다
「아무 일도 없었다」는 「영향이 없다」와 구별되지 않는다. 주입 뒤에는 대상이 실제로 그 상태인지를 확인하는 단계를 하나 더 둔다. 보는 값은 결과가 아니라 상태다 — cluster_size, 워커 PID(프로세스 식별 번호), conntrack 표, 패킷 카운터.
주입 명령이 오류 없이 끝난 것은 걸렸다는 뜻이 아니다. NetworkPolicy 로 7800 을 막았을 때 ESTABLISHED 연결은 conntrack 때문에 규칙 평가를 건너뛰었고, cluster_size 는 25분간 2 로 남았다. 명령 자체는 정상 종료했다.
실패한 주입이 깨끗한 결과를 내기도 한다. A-3 은 DB 를 세 번 죽여 두 번 실패했는데, 그 두 번이 모두 「유실 0건」을 냈다. 무엇을 보면 걸린 것인지 신호를 미리 정해 두지 않았다면 첫 번째 결과를 그대로 답으로 적었을 것이고 결론은 정반대가 됐을 것이다.
재현 가이드 26편이 이 규칙을 절 구조로 갖고 있다. 편마다 주입 전에 평시를 잡는 절이 먼저 오고, 주입 다음에 「주입 검증」 절이 따로 서고, 그 뒤에 관찰과 복구가 온다.
이 규칙의 근거인 아홉 번의 실패는 규칙을 세우기 전에 일어났다. 규칙을 세운 뒤에 이 규칙을 어긴 사례는 따로 적혀 있지 않다.
### 3. 대조군 없이 귀속하지 않는다
평시를 모르면 이상을 해석할 수 없다. 주입 중에 비200 이 한 번 나왔을 때 평시 오류율을 모르면 그것이 주입 탓인지 알 수 없으므로, D-4 에서는 주입 전에 900건을 재서 오류가 0 이라는 것부터 확인했다.
대조군이 변하지 않는다고 가정하지 않는다. A-6 에서는 지연을 주입하지 않은 노드 쪽이 오히려 빨라졌다.
이 실험대가 어긴 사례로 적은 둘이 전부 이 규칙에 걸려 있다. D-4 의 in-flight 76건은 반대로 읽어야 한다 — 거기서는 대조군이 있어서 오보를 막았다.
## 적용 조건
- 상태를 일부러 망가뜨리고 그 영향을 재는 실험 전부에 걸린다.
- 이 실험대가 적용 범위로 말하는 곳은 아홉 번의 실패 이후 이 실험대에서 돌린 실험까지다. 다른 실험실이나 다른 프로젝트까지 넓힌 근거는 이 실험대의 기록에 없다.
- 주입 명령이 오류 없이 끝났을 때도 두 번째 규칙이 걸린다. 종료 코드는 대상의 상태를 말해 주지 않는다.
- 대조군은 주입한 뒤가 아니라 주입 전에 잰다. 주입 뒤에 잰 값에는 이미 주입의 영향이 섞여 있다.
## 예외
- 주입 없이 평시를 관측하는 측정에는 첫 두 규칙이 걸리지 않는다. 망가뜨릴 대상이 없으므로 예측과 주입 확인이 성립하지 않는다.
- 대조군 규칙은 평시 관측에도 걸린다. 무엇을 재든 그 값이 평소 값인지 아닌지를 가르려면 비교할 구간이 필요하다.
## 예시
- A-6 지연 주입 : 지연을 넣지 않은 대조군 노드가 70 ms 에서 41 ms 로 빨라졌는데(−41%) 해설 문서는 처음에 영향 없음이라고 적었다. JIT(just-in-time 컴파일) 워밍업이나 캐시처럼 주입과 무관한 변동이었고, 자릿수가 달라 결론 자체는 유지됐다. 나중에 고쳤다.
- A-8 롤링 재시작 : 5초 간격 표본 9개로 무중단을 주장했다. 고친 문장은 「5초 해상도에서 끊김이 관측되지 않았다」다. 뒤에 1초 간격에 3초 타임아웃으로 D-2 롤백 전환을 재자 파드가 바뀌는 순간 요청 하나가 3초를 넘겼다.
- D-4 in-flight 감시 : 76건이 실패했다. 그대로 적었으면 「갱신 중 대규모 요청 실패」라는 오보가 됐을 것이다. 같은 순간의 폴링은 49건 전부 200 이었고, 실패한 76건은 연결수 0 에 소요 시간 50µs 였고 재현이 0/100 이었다.
- A-1 전송 차단 : 주입을 넣은 뒤 cluster_size 를 따로 읽었고, 25분간 2 로 남아 있는 것을 보고 주입이 걸리지 않았다고 판정했다.
@@ -0,0 +1,155 @@
---
kind: CASE
slug: a-primary-key-without-the-session-id
title: 기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b2
assets:
- key: b2-primary-key-overwrite
file: ../../../final/assets/b2-primary-key-overwrite/b2-primary-key-overwrite.svg
evidence:
- ../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt
- ../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt
---
# 기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다
같은 사용자가 다시 로그인하자 앞의 토큰이 덮어써졌고, 로그아웃해도 토큰 행은 지워지지 않았다. 원인은 저장소 선택이 아니라 기본키였다. JdbcOAuth2AuthorizedClientService 의 기본 스키마는 client_registration_id 와 principal_name 둘로 기본키를 만들고 세션 id 를 넣지 않는다.
## 관계
- **세션과 인가된 클라이언트는 조회 키가 다르다**
두 저장소를 나눠 세운 이유를 그 기록이 설명한다.
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
그 실험이 세션 쪽만 옮겼고, 이 실험은 남은 토큰 쪽을 옮긴 다음을 쟀다.
- **저장소를 옮기기 전에 조회 키를 본다**
여기서 나온 관측을 반복 적용할 기준으로 편 것이다.
- **세션과 토큰의 저장소를 나눠 각각 설계한다**
「각각 설계한다」를 실제로 해 본 결과가 이 실험이다.
## 문제
세션과 토큰을 각각 다른 저장소로 옮기면 상태가 인스턴스 밖으로 나가므로, 다중 인스턴스 때문에 생기던 문제는 여기서 끝나야 한다. 정말 그런지를 네 가지로 나눠 순서대로 확인했다.
두 가지는 기대한 대로 됐는데 두 가지는 아니었다. 같은 사용자가 다른 브라우저로 다시 로그인하자 토큰 행이 늘지 않고 값만 바뀌었고, 로그아웃한 뒤 Redis 는 비었는데 PostgreSQL 에는 토큰 행이 하나 살아 있었다. 저장소를 옮겨서 풀릴 문제였다면 여기서 함께 풀렸어야 했다.
## 결론
네 가지 확인
다른 인스턴스로 요청해도 되는가 : o
재시작 후 로그인 유지 : o
같은 사용자의 다른 브라우저가 덮어쓰는가 : 덮어쓴다
로그아웃하면 두 저장소가 다 정리되는가 : 한쪽만
로그아웃 뒤 두 저장소
Redis 세션 : 0 키
PostgreSQL 토큰 : 1 행. 평문 refresh token 이 들어 있다
남은 두 문제의 원인은 기본키다. JdbcOAuth2AuthorizedClientService 가 쓰는 기본 스키마는 client_registration_id 와 principal_name 둘로만 기본키를 만들고 거기에 세션 id 가 없다. 같은 사용자가 두 번 로그인하면 principal 이름이 같으므로 두 세션이 한 행을 쓰게 되고, 나중 로그인이 앞의 토큰을 덮어쓴다.
저장소를 Redis 로 고르든 PostgreSQL 로 고르든 이 키가 같으면 결과도 같다.
## 검증 환경
실험대 : 베어메탈 한 대(test-server, Arch Linux, 12GB, WiFi only) 위의 VM 두 대
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
애플리케이션 : BFF(Spring Boot) 두 인스턴스
세션 저장소 : Redis
토큰 저장소 : PostgreSQL. 구현은 JdbcOAuth2AuthorizedClientService
토큰 테이블 : oauth2_authorized_client
Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다
Spring Boot 버전 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. 세션 저장소를 Redis 로 두고 토큰 저장소를 JdbcOAuth2AuthorizedClientService 로 바꿔 배포한다.
2. 토큰 테이블의 기본키가 무엇인지 스키마에서 확인한다.
3. 로그인한 뒤 인스턴스 두 대에 번갈아 요청하고, 한 대를 재시작한 뒤 다시 요청한다.
4. 같은 사용자로 두 번째 로그인을 만든다.
브라우저를 바꾸거나 세션만 지우고 다시 로그인시키면 된다. principal 이름이 같으므로 조회 키가 같다.
5. 두 번째 로그인 뒤 토큰 테이블의 행 수와 access token 값을 앞의 것과 견준다.
6. 로그아웃한 뒤 Redis 의 키 수와 토큰 테이블의 행 수를 각각 센다.
## 본문
<!-- body:start -->
## 저장소를 나눠 옮긴 다음
앞 실험에서 이 BFF(Backend For Frontend) 에는 저장소를 직접 만드는 빈이 없다는 것을 확인했다. 무엇이 쓰이는지는 자동구성 결과를 읽어야 알 수 있었는데, 읽어 보니 세션은 서블릿 컨테이너 메모리에 있었고 토큰은 인메모리 구현이 들고 있었다. 세션만 Redis 로 옮겼을 때 토큰이 따라오지 않은 이유도 거기 있었다.
그래서 이번에는 둘을 나눠서 옮겼다. 세션은 Redis 에 두고, 토큰은 `JdbcOAuth2AuthorizedClientService` 로 바꿔 PostgreSQL 에 넣었다. 이 구현은 인가된 클라이언트를 JVM 메모리에 두지 않고 관계형 데이터베이스의 테이블 하나를 읽고 쓴다. 둘 다 인스턴스 밖으로 나갔으니 확인할 것을 네 가지로 적고 하나씩 짚었다.
| 무엇을 확인했나 | 결과 |
|---|---|
| 다른 인스턴스로 요청해도 되는가 | 된다 |
| 재시작 후 로그인 유지 | 된다 |
| 같은 사용자의 다른 브라우저가 덮어쓰는가 | 덮어쓴다 |
| 로그아웃하면 두 저장소가 다 정리되는가 | 아니다. 한쪽만 |
앞의 두 가지는 상태가 인스턴스 밖으로 나갔으므로 풀렸다. 다만 이 둘은 판정만 남고 출력이 없다. 이 실험이 남긴 증거 원문 다섯 개는 배포·스키마·평문 토큰·덮어쓰기·로그아웃 정리인데, 다른 인스턴스로 요청한 화면도 재시작 뒤 로그인을 확인한 화면도 그중에 없다. 뒤의 두 가지와 같은 무게로 읽지 않는다.
뒤의 두 가지는 풀리지 않았다.
## 기본키를 만드는 두 칼럼
토큰 저장소를 바꿔 배포한 직후에는 테이블이 없었다. 파드는 떴고 커넥션 풀도 붙었는데 `oauth2_authorized_client` 를 조회하면 그런 이름의 테이블이 없다고 나왔고, 그것을 신고한 로그는 없었다. 스키마 초기화가 조용히 실패한 것이다.
`spring-security-oauth2-client` 는 DDL 을 두 벌 번들한다. 기본 판본인 `oauth2-client-schema.sql` 은 토큰 칼럼을 `blob` 으로 선언하고 `oauth2-client-schema-postgres.sql``bytea` 로 선언하는데, 기본 판본을 PostgreSQL 에 그대로 태우면 `blob` 에서 문법 오류가 난다. 그 오류를 삼킨 것은 `spring.sql.init.continue-on-error: true` 였다. 없어도 되는 초기화에만 켜는 설정인데 여기서는 없으면 안 되는 초기화였다.
원인을 처음에 Liquibase 의 방언 차이로 적었다가 정정했다. 스키마를 태우는 것은 Spring Boot 의 `spring.sql.init` 이고 DDL 은 `spring-security-oauth2-client` jar 가 번들한 파일이다. Liquibase 는 Keycloak 이 자기 스키마에 쓴다.
그래서 PostgreSQL 판 DDL 을 직접 태우고 만들어진 테이블 정의를 읽었다. 구현만 바꾸면 되겠다고 넘어가지 않고 그 줄을 먼저 본 것은, 조회 키가 코드가 아니라 스키마에 박혀 있기 때문이다. B-0 에서 자동구성이 고른 `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 라는 이름으로 짐작만 하던 것이 여기서 테이블 정의로 확정된다. 기본키를 만드는 칼럼은 둘이었다.
```sql label="인가 클라이언트 테이블의 기본키"
PRIMARY KEY (client_registration_id, principal_name)
```
클라이언트 등록 id 와 principal 이름 둘뿐이고 세션 id 가 없다. 같은 사용자가 브라우저를 바꿔 다시 로그인하면 세션 id 는 새로 발급되지만 principal 이름은 그대로이므로, 두 세션이 같은 행을 쓰게 되고 나중 로그인이 앞의 토큰을 덮어쓴다.
![두 브라우저 세션이 서로 다른 세션 행을 갖지만 토큰 테이블에서는 같은 행을 가리키는 구성](../../../final/assets/b2-primary-key-overwrite/b2-primary-key-overwrite.svg)
그림에서 세션 쪽은 브라우저마다 따로 있는데 토큰 행으로 들어가는 화살표는 둘 다 principal 이름을 타고 같은 행으로 모인다. 덮어쓰기가 일어났는지는 행 수로 알 수 없다. 행은 늘 1 이라 아무 일도 없는 것처럼 보이므로, access token 값이 바뀌었는지를 함께 본다.
두 번째 로그인은 브라우저를 두 개 띄워 만들지 않았다. Redis 에서 세션만 지워 다음 요청이 새 로그인을 만들게 했고, 브라우저가 달라도 principal 이름은 같으므로 조회 키는 그대로다. 그렇게 만든 재로그인 뒤 행 수는 1 이었고 access token 의 md5 가 바뀌었다.
```sql label="한 사용자에게 행이 몇 개 있는지"
select principal_name, count(*) from oauth2_authorized_client group by 1;
```
## 로그아웃이 지우는 쪽과 지우지 않는 쪽
네 번째 확인은 로그아웃이었다. 로그아웃하면 Redis 의 세션 키는 없어지는데, 토큰 테이블에서는 행이 하나도 지워지지 않는다.
```text label="로그아웃한 뒤 두 저장소"
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
```
로그아웃이 끝내는 것은 애플리케이션 세션이고, 인가된 클라이언트는 principal 이름으로 찾는 별개 저장소에 있어서 세션이 사라지는 것과 무관하게 유지된다. 그래서 로그아웃한 사용자의 refresh token 이 평문으로 데이터베이스에 앉아 있게 된다.
평문이라는 것은 칼럼 타입을 보고 짐작한 것이 아니라 저장된 바이트를 꺼내 디코드해서 확인했다. `bytea` 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로였고, refresh token 쪽 헤더의 `alg` 는 `HS512`, access token 쪽은 `RS256` 으로 읽혔다. 데이터베이스 읽기 권한만 있으면 바로 쓸 수 있는 토큰을 얻는다.
## 저장소를 바꿔도 같은 일이 일어난다
덮어쓰기는 어느 저장소에 넣느냐가 아니라 어느 키로 행을 식별하느냐에서 나온다. 같은 스키마를 Redis 에 얹어도 키가 `client_registration_id` 와 `principal_name` 둘이면 같은 사용자의 두 세션이 같은 항목을 가리킨다.
같은 사용자가 두 브라우저를 쓸 때 한쪽의 로그인이 풀리는 것을 세션 만료나 캐시 문제로 보면 저장소만 계속 바꾸게 된다. 고쳐야 하는 것은 테이블을 만드는 DDL(Data Definition Language, 스키마 정의문) 쪽이다.
## 확인하지 않은 것
세션 id 를 키에 넣은 스키마로 고쳐서 다시 재지 않았다. 덮어쓰기가 사라지는지는 확인하지 않았다.
로그아웃할 때 토큰 행까지 지우는 처리를 붙였을 때 무엇이 달라지는지도 이 실험에서는 재지 않았다.
<!-- body:end -->
@@ -0,0 +1,150 @@
---
kind: CASE
slug: moving-the-session-left-the-tokens-behind
title: 세션만 Redis 로 옮기자 토큰이 따라오지 않았다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
lastVerifiedOn: 2026-09-04
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b0
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b1
evidence:
- ../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt
- ../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt
---
# 세션만 Redis 로 옮기자 토큰이 따라오지 않았다
세션 저장소만 Redis 로 바꿨더니 파드를 재시작해도 로그인은 유지됐는데 토큰은 살아남지 못했다. 세션은 세션 id 로 찾고 토큰은 principal 이름으로 찾으므로, 세션 저장소를 바꾸는 설정은 토큰 쪽 경로를 건드리지 않는다.
## 관계
- **세션과 인가된 클라이언트는 조회 키가 다르다**
이 실험이 관측한 결과를 만든 구조를 그 기록이 설명한다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
세션과 따로 옮긴 토큰 저장소에서 무엇이 남았는지를 그 실험이 이어서 쟀다.
- **세션과 토큰의 저장소를 나눠 각각 설계한다**
이 관측이 그 결정의 근거다.
## 문제
BFF 인스턴스가 둘이고 요청이 어느 쪽으로 갈지 모르면 로그인 상태가 인스턴스 안에 있어서는 안 된다. 그래서 세션 저장소를 Redis 로 옮기는 것이 첫 수였다.
문제는 무엇이 옮겨지는지가 코드에 안 적혀 있다는 것이었다. 이 BFF 에는 세션 저장소나 토큰 저장소를 직접 만드는 빈이 없고 전부 자동구성이 고른다. 무엇이 실제로 쓰이는지 모르는 채로 저장소를 붙이면 옮겨진 것과 안 옮겨진 것을 가를 수 없다.
## 결론
자동구성이 고른 것
세션 저장소 : 없음. 서블릿 컨테이너 메모리
토큰 저장소 : InMemoryOAuth2AuthorizedClientService
토큰 조회를 맡는 것 : AuthenticatedPrincipalOAuth2AuthorizedClientRepository
Redis 와 Spring Session : x
세션만 Redis 로 옮긴 뒤
파드 재시작 후 로그인 유지 : o
파드 재시작 후 토큰 생존 : x
Redis 에 들어간 키 : 1개. 타입은 hash
그 해시에 인가된 클라이언트 필드 : x
세션은 세션 id 로 찾고 토큰은 principal 이름으로 찾는다. 조회 경로가 갈라져 있어서 세션 저장소를 무엇으로 바꾸든 토큰은 따라오지 않는다.
## 검증 환경
실험대 : 베어메탈 한 대(test-server, Arch Linux, 12GB, WiFi only) 위의 VM 두 대
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
애플리케이션 : BFF(Spring Boot) 두 인스턴스
세션 저장소 전환 : 환경변수 SPRING_SESSION_STORE_TYPE 을 redis 로
빈 목록 조회 : BFF 의 actuator beans 엔드포인트
Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다
Spring Boot 버전 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. 저장소를 아무것도 붙이지 않은 상태로 BFF 를 띄우고 로그인을 한 번 한다.
2. 빈 목록을 조회해 세션과 토큰에 관계된 구현이 무엇인지 확인한다.
authorizedClientService · authorizedClientRepository · SessionRepository 세 이름을 본다.
3. 환경변수 SPRING_SESSION_STORE_TYPE 을 redis 로 두고 다시 배포한 뒤 로그인한다.
4. Redis 의 키를 세고 그 키의 타입과 필드 이름을 확인한다.
5. BFF 파드를 재시작하고 같은 브라우저로 요청한다.
로그인이 유지되는지와 토큰이 필요한 요청이 되는지를 따로 본다.
## 본문
<!-- body:start -->
## 저장소를 붙이기 전에 무엇이 골라져 있었나
이 BFF 의 코드에는 세션 저장소도 토큰 저장소도 직접 만드는 빈이 없다. 전부 자동구성이 고르므로 Redis 를 붙이기 전에 실행 중인 애플리케이션의 빈 목록을 먼저 읽었다. 전체 빈은 321개였고 그중 세션·토큰 저장소에 관계된 빈은 다섯 줄이었다. 그 다섯 줄에서 토큰을 담는 구현과 토큰을 조회하는 구현을 찾았고, 세션 쪽과 Redis 쪽에는 해당하는 빈이 없었다.
```text label="B-0 · 자동구성이 실제로 고른 구현체"
authorizedClientService → InMemoryOAuth2AuthorizedClientService
authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository
SessionRepository → 없음 (서블릿 컨테이너 in-memory)
Redis / Spring Session → 없음
```
`InMemoryOAuth2AuthorizedClientService` 는 발급받은 access token 과 refresh token 을 JVM 메모리에 담아 두는 기본 구현이다. `SessionRepository` 가 하나도 없다는 것은 Spring Session 이 구성되지 않았다는 뜻이므로, 로그인 상태는 서블릿 컨테이너가 자기 메모리에 들고 있었다. 두 가지 모두 인스턴스 안에 있었다.
빈 목록은 이렇게 뽑는다.
```bash label="무엇이 토큰을 맡고 있는지 확인"
kubectl exec <bff-pod> -- wget -qO- localhost:8083/actuator/beans \
| grep -o '"[a-zA-Z]*OAuth2AuthorizedClient[a-zA-Z]*"' | sort -u
```
## 이름 하나가 조회 키를 말해 준다
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 요청에 딸린 인증 주체(principal)의 이름으로 인가된 클라이언트를 찾아 주는 구현이다. 이름에 적힌 그대로 principal 을 기준으로 찾으므로 조회 키에 세션 id 가 들어가지 않는다.
| | 무엇을 담나 | 무엇으로 찾나 |
|---|---|---|
| Application Session | 누가 로그인했는지 | 세션 id |
| OAuth2AuthorizedClient | access · refresh token | principal 이름 |
같은 요청 하나가 세션은 세션 id 로, 토큰은 principal 이름으로 두 갈래로 조회된다. 이름이 비슷해서 하나로 읽히지만 저장되는 것도 다르고 찾아오는 경로도 다르다.
## 세션만 옮기고 재시작했을 때
배포하자마자 파드가 안 떴다. 쿠버네티스는 같은 네임스페이스의 Service 마다 환경변수를 자동으로 넣는데, 그중 `REDIS_PORT=tcp://10.43.57.116:6379` 가 `application.yml` 의 `${REDIS_PORT:6379}` 를 덮어써 `spring.data.redis.port` 를 int 로 바꾸지 못했다. 환경변수 이름을 바꿔 피할 수도 있었지만 그러면 다음 사람이 같은 함정에 다시 빠지므로, 주입 자체를 끄는 `enableServiceLinks: false` 를 골랐다.
환경변수 `SPRING_SESSION_STORE_TYPE` 을 `redis` 로 두어 Application Session 을 Redis 로 옮겼다. 파드를 재시작해도 로그인은 유지됐다. 브라우저는 다시 로그인 화면을 보지 않았고 같은 세션으로 요청이 이어졌다.
그런데 같은 재시작에서 토큰은 살아남지 못했다. `OAuth2AuthorizedClient` 를 찾는 경로가 principal 이름을 쓰는 인메모리 구현 그대로였기 때문이다. 세션 저장소를 Redis 로 바꾸는 설정은 세션 쪽 경로만 갈아 끼우므로 토큰 쪽은 건드리지 않는다.
## Redis 안에 들어간 것
옮긴 뒤 Redis 를 열어 보니 키가 하나였고 타입은 hash 였다.
```text label="B-1 · Redis 에 들어 있던 세션 키"
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
타입: 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
TTL: 1772 초
```
필드 이름에 토큰이 안 보이는 것과 값 안에 토큰이 없는 것은 다른 말이라, 필드 값도 직접 꺼내 토큰 문자열이 섞여 있는지 봤다. `sessionAttr:SPRING_SECURITY_CONTEXT` 의 값은 `\xac\xed` 두 바이트로 시작한다. Java 기본 직렬화의 매직 넘버라, Redis 에 들어간 값은 같은 클래스패스의 JVM 이 있어야 읽힌다. 그 안에 든 것은 누가 로그인했는지를 말하는 인증 객체다. 일곱 필드 어느 이름도 access token 이나 refresh token 을 가리키지 않는다.
값을 꺼내 본 것은 `sessionAttr:SPRING_SECURITY_CONTEXT` 하나뿐이다. 이름에 OAuth2 가 들어간 `…HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST` 는 이름만 보고 넘어갔고 값 안을 열어 보지 않았다.
로그인 상태가 Redis 로 나간 것은 맞지만 토큰은 아직 인스턴스 메모리에 있었다. 여기서 「무상태가 됐다」고 판단하면 다른 인스턴스로 간 요청이 토큰을 못 찾는 이유를 설명할 수 없게 된다.
## 확인하지 않은 것
Redis 를 붙인 상태에서 토큰 저장소만 따로 두는 조합은 B-2 에서 다시 쟀다. 여기서는 세션 쪽만 확인했다. 그 실험이 확인한 네 가지 가운데 둘은 저장소를 밖으로 빼는 것으로 풀렸고 둘은 풀리지 않았는데, 남은 둘의 원인은 저장소가 아니라 토큰 테이블의 기본키였다.
파드 재시작 한 번으로 본 결과이고, 두 인스턴스 사이로 요청을 번갈아 보내 토큰이 어느 쪽에서 사라지는지까지는 이 실험에서 재지 않았다.
<!-- body:end -->
@@ -0,0 +1,149 @@
---
kind: CASE
slug: persistence-without-a-volume-and-rotation-without-overlap
title: 볼륨 없는 영속화와 유예 없는 키 회전
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b5-b6
assets:
- key: b5-b6-storage-and-keys
file: ../../../final/assets/b5-b6-storage-and-keys/b5-b6-storage-and-keys.svg
evidence:
- ../../../final/evidence/raw/b5-redis-loss__04-persistence.txt
- ../../../final/evidence/raw/b6-key-rotation__03-old-key-removed.txt
---
# 볼륨 없는 영속화와 유예 없는 키 회전
appendonly 를 켠 Redis 파드를 지웠더니 재기동 뒤 dbsize 가 0 이었고, realm 키 회전에서 기대한 JWKS 캐시 유예 구간은 없었다. 데이터 디렉터리가 컨테이너와 함께 사라졌고, NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져오므로 회전은 끊기지 않았다.
## 관계
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
그 실험이 상태를 Redis 로 옮겼고, 이 실험은 그 Redis 가 죽었을 때 무엇이 남는지를 쟀다.
- **저장소를 옮기기 전에 조회 키를 본다**
저장소를 고를 때 설정과 매체를 나눠 보는 이 관측이 그 기준에 붙는다.
## 문제
세션을 Redis 로 옮기고 나면 Redis 가 내려갔을 때 무엇이 남는지가 다음 물음이 된다. 영속화 설정은 명령 한 줄로 켤 수 있고 켜고 나면 설정 조회에도 yes 로 나오므로, 설정만 보면 로그인 상태가 재기동 뒤에도 살아남는 구성으로 읽힌다.
키 쪽도 비슷하다. realm 의 서명 키를 바꾸면 그 전에 발급된 토큰이 한동안 남아 있는데, 검증하는 쪽이 옛 키를 언제까지 받아 주는지는 캐시 설정에 안 적혀 있다. 두 곳 모두 설정 값만으로는 답이 나오지 않아서 실제로 죽여 보고 회전시켜야 했다.
## 결론
Redis 영속화
appendonly 설정 : yes 로 적용된다
appendonlydir 생성 : o
파드를 지운 뒤 재기동 : dbsize 0. 심어 둔 키 둘 다 없음
재기동 후 appendonly : no
원인 : 데이터 디렉터리가 컨테이너 파일시스템이라 컨테이너가 사라질 때 같이 사라진다
realm 키 회전
기대한 JWKS 캐시 유예 구간 : x
모르는 kid 를 만난 검증기의 동작 : 캐시 만료를 안 기다리고 JWKS 재조회
옛 키를 남겨 둔 동안 옛 토큰 검증 : 이어진다
옛 키 공급자를 제거한 뒤 옛 토큰 : 401
같은 시점의 새 토큰 : 200
설정 값과 그 설정이 얹힌 매체는 따로 봐야 한다. 영속화는 설정이 맞아도 매체가 사라지면 뜻이 없고, 키 회전은 설정에 유예가 없어도 검증기가 다시 가져오므로 끊기지 않는다.
## 검증 환경
실험대 : 베어메탈 한 대(test-server, Arch Linux, 12GB, WiFi only) 위의 VM 두 대
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
세션 저장소 : Redis. 볼륨을 붙이지 않은 상태
영속화 설정 : redis-cli 로 appendonly 를 yes 로
리소스 서버 토큰 검증 : NimbusJwtDecoder
키 회전 방식 : 새 RSA 공급자를 더 높은 priority 로 추가
Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. 볼륨을 붙이지 않은 Redis 에 키를 두어 개 심고 dbsize 를 센다.
2. appendonly 를 yes 로 바꾸고 설정 조회로 적용됐는지 확인한다.
데이터 디렉터리에 appendonlydir 이 생겼는지도 본다.
3. Redis 파드를 지우고 다시 뜨기를 기다린 뒤 dbsize 와 심어 둔 키를 다시 조회한다.
appendonly 값도 같이 본다.
4. realm 에 새 RSA 키 공급자를 더 높은 priority 로 추가한다.
5. 회전 전에 받아 둔 옛 토큰과 회전 뒤에 받은 새 토큰으로 보호된 요청을 각각 보낸다.
6. 옛 RSA 공급자를 제거하고 5 번을 다시 보낸다.
리소스 서버를 재시작해 JWKS 캐시를 비운 뒤에도 한 번 더 보낸다.
## 본문
<!-- body:start -->
## 영속화를 켜도 재기동 뒤에 아무것도 없었다
Redis 에 세션을 두었으니 Redis 가 내려갔다 올라올 때 세션이 남는지를 봐야 했다. 손대기 전 Redis 는 영속화가 아예 꺼져 있었다. `config get save` 의 값 줄이 비어 있어 스냅샷 조건이 없었고 `appendonly``no` 였다.
`appendonly` 는 Redis 가 받은 쓰기 명령을 파일에 덧붙여 적어 두고 재기동할 때 그 파일을 다시 재생하는 설정이다. 실행 중에 `redis-cli config set` 으로 켤 수 있고, 켜고 나면 설정 조회에 `yes` 로 나오고 데이터 디렉터리에 `appendonlydir` 도 생긴다. 여기까지가 설정 조회로 볼 수 있는 전부이고, 이 둘만 보면 켜진 것과 남는 것이 같은 말로 보인다.
그래서 읽는 대신 지웠다. 키를 심고 그 상태에서 파드를 지웠다.
```text label="B-5 · 파드를 지우고 재기동한 뒤"
재기동 후:
dbsize: 0
b5:probe
b5:aof
appendonly no
```
심어 둔 키 둘은 값이 비어 있었고 데이터베이스 크기는 0 이었다. `appendonly` 도 `no` 로 돌아와 있었는데, 실행 중에 바꾼 설정이라 새로 뜬 컨테이너는 이미지의 기본 설정으로 시작하기 때문이다.
## 파일을 어디에 적었는지가 남는 것을 정한다
덧붙여 적는 파일이 컨테이너 안의 `/data` 에 있었다. 컨테이너 파일시스템은 컨테이너가 사라질 때 같이 사라지므로, 파드를 지우는 순간 `appendonlydir` 도 없어졌다. 설정은 제대로 적용됐고 파일도 만들어졌는데 그 파일을 둔 곳이 컨테이너와 수명을 같이했다.
Redis 는 이것을 모른다. `appendonly yes` 를 켜면 `/data` 에 `appendonlydir` 을 만들고 매 쓰기를 기록한다. 거짓말을 하는 것이 아니라 정말로 기록하고, 다만 그 디렉터리가 어디에 얹혀 있는지를 모를 뿐이다. 그래서 설정 조회로는 이 실패가 보이지 않고, 대신 파드 명세에서 세 가지가 전부 성립하는지를 본다. `volumes` 에 항목이 있는지, 그 `volumeMounts` 의 `mountPath` 가 `/data` 인지, `PersistentVolumeClaim` 이 `Bound` 인지. 하나라도 빠지면 `appendonly yes` 는 아무것도 지키지 못하고, 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.
`emptyDir` 로 붙여도 마찬가지다. 컨테이너 재시작은 견디지만 파드가 없어지면 같이 없어지므로, 볼륨을 붙였다는 것과 영속 볼륨을 붙였다는 것은 다른 말이다.
![appendonly 설정이 데이터 디렉터리에 파일을 쓰고, 그 디렉터리가 컨테이너와 함께 사라지거나 PersistentVolume 에 마운트되는 두 갈래](../../../final/assets/b5-b6-storage-and-keys/b5-b6-storage-and-keys.svg)
그림에서 `appendonly yes` 는 `/data` 로 이어지고, `/data` 에서 나가는 화살표는 둘이다. 하나는 컨테이너와 함께 사라지는 쪽이고 하나는 `PersistentVolume` 으로 마운트되는 쪽이다. 위 측정은 마운트가 없는 상태였으므로 왼쪽 화살표만 실제로 일어났다.
지금 이 실험대의 매니페스트는 오른쪽이다. 측정한 결과가 그대로 반영되어 PersistentVolumeClaim 과 `--appendonly yes` 가 들어 있고, 그래서 같은 측정을 다시 하려면 볼륨 없는 상태를 먼저 되만드는 단계가 앞에 붙는다.
## realm 키를 회전했을 때 기대한 유예 구간
원래 물어야 했던 것은 토큰 저장소의 암호화 키를 어디에 두고 어떻게 교체하느냐였다. 그런데 앞 실험에서 `bytea` 안을 열어 보니 JWT 문자열이 그대로 들어 있었다. 저장소 쪽에는 교체할 키가 없으므로 남은 것은 서명 키 하나이고, 그래서 realm 의 RSA 공급자를 회전시켰다.
서명 키 쪽은 반대 방향으로 어긋났다. 토큰을 검증하는 쪽은 JWKS(JSON Web Key Set, 발급자가 서명에 쓴 공개키 목록)를 받아 캐시해 두고, 토큰 헤더의 `kid` 로 어느 키가 서명했는지 찾는다. 캐시가 있으니 회전 직후에는 옛 키 목록을 들고 있을 것이고, 캐시가 만료될 때까지 새 키로 서명된 토큰이 거부되는 구간이 있을 것으로 봤다.
그런 구간이 없었다. `NimbusJwtDecoder` 는 Spring Security 가 쓰는 JWT 검증기인데, 캐시에 없는 `kid` 를 만나면 만료를 기다리지 않고 JWKS 를 곧바로 다시 가져온다. 예측이 틀렸고, 회전은 그 구간 없이 그대로 이어졌다.
회전은 키 공급자의 우선순위로 한다. 새 키를 더 높은 priority 로 추가하면 그 뒤에 발급되는 토큰은 새 키로 서명되고, 옛 키는 목록에 있으므로 회전 전에 나간 토큰의 검증도 이어진다. JWKS 의 RS256 키는 회전 전 1개에서 회전 뒤 2개가 됐고, 회전 전에 받아 둔 토큰과 회전 뒤에 받은 토큰이 둘 다 200 이었다.
## 옛 키 공급자를 지우면 그때 끊긴다
옛 RSA 공급자를 realm 에서 제거하면 JWKS 에서 그 키가 빠진다. 그 시점부터 옛 키로 서명된 토큰은 검증할 공개키가 없다. 제거한 뒤 JWKS 를 다시 받아 RS256 키가 하나만 남은 것을 확인하고 나서 두 토큰을 보냈다.
```text label="B-6 · 옛 키 공급자를 제거한 뒤"
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
```
리소스 서버를 재시작해 JWKS 캐시를 비운 뒤에도 같은 값이 나왔다. 옛 토큰은 401, 새 토큰은 200 이었다. 회전에서 옛 토큰을 살려 두는 것은 캐시가 아니라 realm 에 옛 키 공급자를 남겨 두는 쪽이다.
키 교체라는 한 낱말이 여기서는 성질이 반대인 두 조작이다. 키를 더하는 쪽은 아무것도 끊지 않고 JWKS 에 옛 키와 새 키가 함께 남는다. 옛 키를 버리는 쪽은 그 순간부터 끊는다. 위험한 것은 회전 자체가 아니라 옛 키를 언제 버리느냐다.
버린 키는 돌아오지도 않는다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생겨 `kid` 가 달라지므로 옛 키로 서명된 토큰은 영구히 검증되지 않는다. 이 실험에서 되돌릴 수 있었던 것은 새로 만든 공급자를 지우는 쪽 하나뿐이었다.
## 확인하지 않은 것
볼륨을 붙인 Redis 로 B-5 전체를 다시 돌리지 않았다. 증거 원문에는 PersistentVolumeClaim 을 붙이고 키 하나를 심은 뒤 파드를 지웠더니 재기동 후에도 그 키가 남아 있었다는 기록이 한 번 있다. 다만 영속화가 어느 시점까지 복구하는지는 이 실험에서 재지 않았다.
키 회전 쪽은 옛 공급자를 남긴 경우와 제거한 경우 두 가지만 걸었다. 옛 키를 얼마나 오래 남겨 두어야 나간 토큰이 전부 만료되는지는 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,159 @@
---
kind: CASE
slug: the-winner-of-the-rotation-race-also-loses
title: 회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
lastVerifiedOn:
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b3
assets:
- key: b3-rotation-contention
file: ../../../final/assets/b3-rotation-contention/b3-rotation-contention.svg
evidence:
- ../../../final/evidence/raw/b3-refresh-contention__01-concurrent-refresh.txt
- ../../../final/evidence/raw/b3-refresh-contention__02-session-impact.txt
- ../../../final/evidence/raw/b3-refresh-contention__03-client-session-removed.txt
- ../../../final/evidence/raw/b3-refresh-contention__04-policy-comparison.txt
---
# 회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다
같은 refresh token 으로 동시에 5건을 보내자 이긴 요청의 새 토큰도 쓸 수 없었다. 경쟁이 감지되자 Keycloak 이 client session 을 지웠기 때문이다. 다섯 전부 못 쓰게 되므로 재시도를 「실패한 것만 다시 보낸다」로 설계할 수 없고, 복구하려면 다시 로그인해야 한다.
## 관계
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
그 실험은 저장된 토큰이 서로를 덮어쓰는 것을 쟀고, 이 실험은 같은 토큰을 동시에 쓸 때를 쟀다.
- **세션과 토큰의 저장소를 나눠 각각 설계한다**
토큰 쪽 설계에 재시도가 왜 따라붙는지를 이 관측이 댄다.
## 문제
토큰 갱신에 성공하면 Keycloak 은 새 refresh token 을 발급하면서 옛것을 소비된 것으로 표시하므로, 그 토큰이 다시 오면 재사용으로 본다. 이 realm 은 회전을 켜고 재사용 허용을 0 으로 두어 한 번만 쓸 수 있게 해 두었다.
인스턴스가 둘이고 요청이 어느 쪽으로 갈지 모르는 구성에서는 두 replica 가 같은 refresh token 을 거의 같은 시각에 쓸 수 있다. 이때 하나가 이기고 나머지가 400 을 받는다면 진 요청만 다시 보내는 재시도로 복구된다. 실제로 그렇게 갈리는지를 재야 했다.
## 결론
realm 설정
revokeRefreshToken : true
refreshTokenMaxReuse : 0
같은 refresh token 으로 동시에 5건을 보낸 결과
순차 실행으로 재현 : x
이긴 요청이 새 토큰을 받았는가 : o
이긴 요청의 새 토큰을 쓸 수 있었는가 : x
원인 : 경쟁이 감지되자 Keycloak 이 client session 을 지웠다
회전을 느슨하게 둔 두 구성을 같은 절차로 더 걸었을 때
revokeRefreshToken false : 5건 다 200. client session 1 남음
refreshTokenMaxReuse 1 : 성공 2 / 5. 이긴 토큰 재사용 400. client session 0
「하나는 성공하고 나머지가 실패한다」가 아니라 전부 못 쓰게 되는 쪽이었다. 새 토큰 자체가 잘못 발급된 것이 아니라 그 토큰이 매달린 세션이 방금 사라졌기 때문이다.
그래서 이 상황의 복구는 실패한 요청을 다시 보내는 것으로 끝나지 않고 재인증까지 가야 한다.
## 검증 환경
실험대 : 베어메탈 한 대(test-server, Arch Linux, 12GB, WiFi only) 위의 VM 두 대
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
애플리케이션 : BFF(Spring Boot) 두 인스턴스. 노드마다 하나씩 떴다
회전 설정 : revokeRefreshToken true · refreshTokenMaxReuse 0
정책 비교로 더 건 두 구성 : revokeRefreshToken false · refreshTokenMaxReuse 1
동시 요청 수 : 5. 세 구성 모두 같은 수로 걸었다
Keycloak 패치 버전 : 이 측정 기록에 적혀 있지 않다
## 재현 조건
1. realm 의 회전 설정을 확인한다.
revokeRefreshToken 이 true 이고 refreshTokenMaxReuse 가 0 이어야 한다.
2. 로그인해서 refresh token 을 하나 확보한다.
3. 그 토큰 하나로 토큰 갱신 요청 5건을 동시에 보낸다.
셸에서 각 요청을 백그라운드로 띄우고 wait 으로 모은다. 순차로 돌리면 경합이 생기지 않아 재현되지 않는다.
4. 5건의 응답 코드를 각각 기록한다.
5. 200 을 받은 요청이 가져온 새 access token 으로 보호된 요청을 한 번 보낸다.
6. 그 사용자의 세션에 client session 이 몇 개 남았는지 확인하고, 새로 만든 정상 세션과 견준다.
같은 시점에 revoked_token 행 수도 센다.
7. 회전 설정을 바꾸기 전에 세션을 새로 만든다.
앞 구성에서 파괴된 세션으로 재면 무엇을 바꾸든 다섯 다 400 이라 비교가 성립하지 않는다.
8. 회전을 끈 구성과 refreshTokenMaxReuse 를 1 로 둔 구성에서 3 번부터 6 번까지를 각각 다시 돌린다.
## 본문
<!-- body:start -->
## 회전을 켜면 옛 토큰이 소비된 것으로 표시된다
토큰 갱신에 성공하면 Keycloak 은 새 refresh token 을 발급하면서 방금 쓴 토큰을 소비된 것으로 표시한다. 소비된 토큰이 다시 오면 재사용으로 보고 거절하는데, 얼마나 엄격하게 볼지는 realm 설정 두 개가 정한다.
| 설정 | 무엇을 바꾸나 |
|---|---|
| revokeRefreshToken | 회전을 켠다. 옛 토큰을 소비 처리 |
| refreshTokenMaxReuse=0 | 한 번만 쓸 수 있다 (가장 엄격) |
| refreshTokenMaxReuse=1 | 같은 토큰을 두 번까지 허용 — 네트워크 재시도를 견디려는 값 |
동시 5건을 처음 던진 것은 가장 엄격한 쪽인 `refreshTokenMaxReuse=0` 에서였다. 지금 realm 이 어느 쪽인지는 관리 명령으로 확인한다.
```bash label="realm 의 회전 설정"
kcadm get realms/<realm> --fields revokeRefreshToken,refreshTokenMaxReuse
```
## 순차로 보내면 아무 일도 일어나지 않는다
replica 둘이 같은 refresh token 을 쓰는 상황을 만들려면 요청이 실제로 겹쳐야 한다. 같은 토큰으로 5건을 차례로 보내면 첫 건이 갱신하고 나머지가 소비된 토큰을 들고 오므로 재사용 판정이 순서대로 나올 뿐이다. 그래서 셸에서 요청 5건을 `&` 로 띄우고 `wait` 으로 한꺼번에 모아 같은 시각에 도착하게 만들었다.
부하 도구는 쓰지 않았다. 묻는 것이 초당 몇 건까지 견디는가가 아니라 겹치면 무엇이 부서지는가라서, 둘만 겹쳐도 답은 나온다. 5건으로 잡은 것은 뒤에 나오는 오류 문구 두 종류가 한 화면에 같이 보이기 때문이지 다섯이 필요해서가 아니다.
## 이긴 요청의 토큰도 쓸 수 없었다
동시에 보낸 5건 가운데 하나는 새 토큰을 받았다. 나머지 넷이 받은 400 은 한 가지가 아니었다. 한 건만 `Maximum allowed refresh token reuse exceeded` 였고 나머지 셋은 `Session doesn't have required client` 였다 — 뒤의 셋은 회전 판정이 아니라 세션 쪽에서 거절됐다.
이긴 요청의 새 토큰으로 보호된 요청을 보내자 그것도 통하지 않았다. 재사용이 감지된 순간 Keycloak 이 그 토큰만 막은 것이 아니라 세션 쪽을 지웠기 때문이다. 이긴 요청이 받은 토큰은 방금 사라진 client session 에 매달려 있어서 검증을 통과하지 못한다.
이긴 요청이 200 을 받은 것 자체는 어긋난 값이 아니다. 그 요청은 먼저 처리를 시작해 새 토큰 발급까지 갔고, 그 사이에 뒤늦게 도착한 것들이 같은 옛 토큰을 들고 오면서 재사용 탐지를 깨웠다. client session 이 지워진 것은 그 다음이고, 200 응답은 이미 성공이 확정된 상태로 나간다. 두 일의 순서가 전부라서, 응답 코드를 아무리 봐도 그 토큰이 죽었다는 것은 안 보인다.
이 판정은 값 하나만 보고 내리지 않았다. 경합을 겪은 sid 는 user session 행이 남아 있고 client session 만 0 이었는데, 그 0 이 원래 그런 값인지 지워진 결과인지는 값 하나로 구분되지 않는다. 그래서 정상 세션을 하나 새로 만들어 같은 질의를 걸었고, 그쪽은 1 이었다.
토큰을 폐기 목록에 올려 막은 것일 가능성도 함께 지웠다. 같은 시점에 `revoked_token` 을 세었더니 0 행이었다. 토큰을 지우는 방식이었다면 그 client 의 다른 토큰은 살아 있어야 하는데, 여기서는 그 client 몫이 한꺼번에 죽는다.
![동시에 도착한 refresh 요청들이 경쟁을 일으키고, 그 결과 client session 자체가 지워지는 구성](../../../final/assets/b3-rotation-contention/b3-rotation-contention.svg)
그림에서 동시 요청 다섯이 회전 검사로 모이고, 거기서 나가는 화살표는 client session 을 지우는 쪽으로 간다. 이긴 요청의 새 토큰으로 향하는 선은 그 삭제 뒤에 놓인다. 「하나는 성공하고 나머지가 실패한다」가 아니라 다섯 전부 못 쓰게 되는 쪽이었다.
## 재시도 설계가 여기서 갈린다
실패가 진 요청에만 온다면 재시도는 400 을 받은 쪽만 다시 보내면 끝난다. 200 을 받은 요청까지 쓸 수 없으므로 그 설계로는 복구되지 않고, 이 사용자는 다시 로그인해야 한다.
그러면 회전 설정을 느슨하게 두는 쪽은 어떤지를 같은 절차로 두 번 더 걸었다. 걸기 전에 세션을 새로 만드는 것이 전제인데, 방금 파괴된 세션으로 재면 설정을 무엇으로 두든 다섯 다 400 이라 비교가 성립하지 않는다.
| 회전 설정 | 성공 | 이긴 토큰 재사용 | 남은 client session |
|---|---|---|---|
| revokeRefreshToken true · maxReuse=0 | 1 / 5 | 400 | 0 |
| revokeRefreshToken false | 5 / 5 | 200 | 1 |
| revokeRefreshToken true · maxReuse=1 | 2 / 5 | 400 | 0 |
회전을 끄면 경쟁 자체가 성립하지 않는다. 같은 refresh token 을 계속 쓸 수 있으니 다섯 다 200 이고 세션도 남는다. 대신 토큰이 유출되면 만료까지 계속 쓸 수 있고, 회전이 좁히려던 것이 바로 그 구간이다.
`refreshTokenMaxReuse` 를 1 로 올린 쪽은 절충이 되지 못했다. 성공이 1에서 2로 늘었을 뿐 남은 client session 은 0 그대로이고 이긴 토큰도 여전히 400 이다. 동시 요청이 N 개면 `refreshTokenMaxReuse` 가 N-1 이상이어야 한다는 계산이 여기서 따라 나오는데, 그러려면 몇 개까지 동시에 올지를 먼저 알아야 하고 그 답은 이 실험에 없다. N 을 바꿔 가며 재 보지도 않았으므로 이 계산은 두 구성에서 따라 나온 추론이다.
남는 길은 갱신을 직렬화하는 쪽이고, 그 잠금은 프로세스 밖에 있어야 한다 — 프로세스 안의 `synchronized` 는 replica 를 넘지 못한다. 후보로 PostgreSQL 행 잠금과 Redis 분산 lock 과 갱신 전용 인스턴스를 적어 두었지만 어느 것을 넣었을 때 이 재현이 사라지는지는 재지 않았다. 이 실험이 닫은 것은 무엇이 부서지는가까지다.
## 확인하지 않은 것
재시도 설계를 붙여서 다시 재지 않았다. 어떤 재시도가 이 상황을 복구하는지는 확인하지 않았다.
동시 요청 수는 5건 한 가지만 걸었다. 2건이나 20건에서 같은 결과가 나오는지는 세 구성 어디에서도 재지 않았다.
BFF 를 거쳐 같은 경쟁이 나는지도 재지 않았다. Keycloak 쪽 동작만 갈라 보려고 토큰 엔드포인트를 직접 쳤기 때문이다.
<!-- body:end -->
@@ -0,0 +1,117 @@
---
kind: CONCEPT
slug: two-stores-two-lookup-keys
title: 세션과 인가된 클라이언트는 조회 키가 다르다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
basisVersion: Spring Boot 3 · Spring Security OAuth2 Client · Spring Session Redis
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b0
assets:
- key: bff-store-lookup-keys
file: ../../../final/assets/bff-store-lookup-keys/bff-store-lookup-keys.svg
evidence:
- ../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt
- ../../../final/evidence/raw/b1-redis-session-store__02-autoconfig-after.txt
- ../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt
- ../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt
- ../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt
---
# 세션과 인가된 클라이언트는 조회 키가 다르다
BFF 가 서버에 두는 상태는 둘이고, 세션은 세션 id 로 토큰은 principal 이름으로 찾는다. 조회 키가 이렇게 갈라져 있어서 세션 저장소만 Redis 로 바꿔도 OAuth2AuthorizedClient 는 따라오지 않는다.
## 관계
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
여기서 설명하는 조회 키 차이가 실제 배포에서 어떤 결과를 냈는지 잰 기록이다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
principal 이름으로 찾는다는 것이 테이블 기본키 한 줄로 드러난 대목을 이어서 다룬다.
- **세션과 토큰의 저장소를 나눠 각각 설계한다**
두 저장소를 따로 옮긴 뒤 무엇이 풀리고 무엇이 남았는지를 적은 기록이다.
## 본문
<!-- body:start -->
## 자동 구성이 고르는 네 가지
Redis 도 Spring Session 도 붙이지 않은 Spring Boot BFF(Backend for Frontend, 프런트엔드 하나를 위해 두는 백엔드) 에서 자동 구성이 골라 둔 것은 넷이다.
```
authorizedClientService → InMemoryOAuth2AuthorizedClientService
authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository
SessionRepository → 없음 (서블릿 컨테이너 in-memory)
Redis / Spring Session → 없음
```
둘째 줄의 `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 인증된 principal 을 기준으로 인가된 클라이언트를 꺼내 오는 구현이고, principal 이름으로 찾기 때문에 조회 키에 세션 id 가 없다. 첫째 줄의 `InMemoryOAuth2AuthorizedClientService` 는 이름 그대로 인스턴스 메모리에 두고, `SessionRepository` 는 아예 없어서 세션도 서블릿 컨테이너 메모리에 남는다.
이 목록이 아무것도 안 줬을 때의 값인 것은 배포 순서를 그렇게 짜 두었기 때문이다. 배포 매니페스트의 머리 주석이 그 순서를 적어 놓았다.
```text
# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate:
# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is
# configured, and the only honest way to answer is to look at a running instance
# that has been given nothing. Redis is deployed alongside but left unused until
# B-1 turns it on.
```
Redis 는 같은 매니페스트로 함께 올라가 있었고 연결만 안 했다.
## 한 요청이 두 갈래로 조회된다
이름은 비슷해도 담는 것이 다른 두 가지가 따로 굴러간다.
| | 무엇을 담나 | 조회 키 |
|---|---|---|
| Application Session | 누가 로그인했는지 | 세션 id |
| OAuth2AuthorizedClient | access · refresh token | principal 이름 |
같은 요청이 세션은 세션 id 로, 토큰은 principal 이름으로 두 갈래로 조회된다.
![세션 id 로 찾는 Application Session 과 principal 이름으로 찾는 OAuth2AuthorizedClient 가 각각 다른 저장소에 놓인 구성](../../../final/assets/bff-store-lookup-keys/bff-store-lookup-keys.svg)
## 세션만 Redis 로 옮겼을 때 남은 것
`SPRING_SESSION_STORE_TYPE=redis` 로 Application Session 을 Redis 로 옮겨도 토큰은 같이 살아남지 못한다. 조회 키가 다르므로 세션 저장소를 바꿔도 `OAuth2AuthorizedClient` 는 따라오지 않고, 옮기기 전과 뒤의 빈 목록에서도 authorizedClientService 와 authorizedClientRepository, authorizedClientManager 셋이 그대로였다.
옮긴 뒤 Redis 에는 키가 하나 있었고 타입은 hash 였다.
```
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
타입: 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
TTL: 1772 초
```
일곱 필드 어느 이름도 access token 이나 refresh token 을 가리키지 않는다. 값까지 꺼내 본 필드는 `sessionAttr:SPRING_SECURITY_CONTEXT` 하나이고, 그 값은 `\xac\xed` 두 바이트로 시작한다 — Java 기본 직렬화의 매직 넘버다. 이름에 OAuth2 가 들어간 `…AUTHORIZATION_REQUEST` 는 값 안을 열어 보지 않았으므로, 그 필드에 무엇이 들어 있는지는 이 실험대가 답하지 않는다.
## 조회 키가 기본키로 드러나는 곳
토큰은 `JdbcOAuth2AuthorizedClientService` 로 PostgreSQL 에 옮겼고, 그때 만들어진 테이블의 기본키가 조회 키를 그대로 적어 놓는다.
```sql
PRIMARY KEY (client_registration_id, principal_name)
```
두 컬럼 어디에도 세션 id 가 없다. 그래서 같은 사용자의 두 세션이 같은 행을 쓰게 되고, 나중 로그인이 앞의 토큰을 덮어쓴다.
## 지금 확인한 범위
조회 키가 principal 이름이라는 것은 두 곳에서 나온다. 아무것도 설정하지 않았을 때 자동 구성이 고른 구현체 이름과, 토큰을 PostgreSQL 로 옮겼을 때 만들어진 테이블의 기본키다. Spring Security 소스를 열어 조회 경로를 따라가지는 않았다.
여기 적은 것은 인스턴스가 둘 이상인 구성의 이야기다. 단일 인스턴스에서는 이 질문 자체가 생기지 않는다 — 저장소를 붙이기 전에는 세션도 인가된 클라이언트도 같은 프로세스 메모리에 있다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: PROJECT_DECISION
slug: split-the-two-stores-and-design-each
title: 세션과 토큰의 저장소를 나눠 각각 설계한다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
decisionStatus: ADOPTED
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b2
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답
evidence:
- ../../../final/evidence/raw/b1-redis-session-store__02-autoconfig-after.txt
- ../../../final/evidence/raw/b2-multi-instance-session__01-jdbc-store-deploy.txt
- ../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt
- ../../../final/evidence/raw/b2-multi-instance-session__03-plaintext-tokens.txt
- ../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt
- ../../../final/evidence/raw/b2-multi-instance-session__05-logout-cleanup.txt
---
# 세션과 토큰의 저장소를 나눠 각각 설계한다
로그인 세션은 Redis 에, OAuth 토큰은 PostgreSQL 에 따로 두기로 했다. 한 저장소로 묶는 쪽을 먼저 시도했다가 세션만 옮겨지는 것을 보고 나눴다. 나눈 뒤에도 덮어쓰기와 로그아웃 정리는 풀리지 않았고 원인은 저장소가 아니라 기본키였다.
## 근거
- **세션과 인가된 클라이언트는 조회 키가 다르다**
세션은 세션 id 로 찾고 토큰은 principal 이름으로 찾는다는 것이 나누기로 한 전제다.
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
한 저장소로 묶는 대안을 실제로 적용해 보고 반쪽만 옮겨지는 것을 확인했다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
나눠 옮긴 뒤에 무엇이 풀리고 무엇이 풀리지 않는지를 그 실험이 잰다.
## 결정문
Application Session 은 Redis 에 두고 OAuth2AuthorizedClient 는 JdbcOAuth2AuthorizedClientService 로 PostgreSQL 에 둔다. 두 저장소를 한 덩어리로 다루지 않고 각각 고른다.
인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 모르는 구성에만 해당한다. 단일 인스턴스에서는 이 질문이 생기지 않는다.
## 판단 이유
아무것도 설정하지 않은 Spring Boot 애플리케이션에서 자동구성이 무엇을 고르는지 먼저 봤다. 토큰 쪽은 AuthenticatedPrincipalOAuth2AuthorizedClientRepository 가 잡혔고, 이 구현은 principal 이름으로 찾으므로 조회 키에 세션 id 가 없다. 세션 쪽은 세션 id 로 찾는다. 이름이 비슷한 두 개가 서로 다른 키로 굴러간다.
한 저장소로 묶는 쪽을 먼저 시도했다. SPRING_SESSION_STORE_TYPE=redis 로 Application Session 을 Redis 로 옮기고 빈 구성을 전후로 비교했더니 authorizedClientService, authorizedClientRepository, authorizedClientManager 셋 다 옮기기 전과 같았다. 세션 저장소를 바꿔도 OAuth2AuthorizedClient 는 따라오지 않는다.
그래서 토큰은 JdbcOAuth2AuthorizedClientService 로 PostgreSQL 에 따로 옮겼다. 이 문서가 Q3 에 낸 답도 같다 — "둘은 조회 키가 다르므로 각각 결정해야 한다. 세션을 Redis 로 옮겨도 토큰은 따라오지 않는다".
토큰 쪽 후보는 셋이었다. JdbcOAuth2AuthorizedClientService, Redis 로 직접 구현하는 것, 그리고 세션 안에 넣는 HttpSessionOAuth2AuthorizedClientRepository. 앞의 둘은 같은 인터페이스라 컨트롤러를 안 고쳐도 되지만, 둘 다 principal 이름으로 찾으므로 같은 사용자의 두 세션이 같은 행을 쓰는 것을 막지 못한다. 셋째는 Repository 쪽으로 바꿔야 하지만 세션 단위로 저장되므로 같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않고, 그 대신 세션이 커진다. 셋째를 안 고른 것은 Q3 가 Redis 와 JDBC 중 무엇이냐를 물었기 때문이다. 그 대가가 아래 영향 절의 셋째와 넷째다.
## 영향
나눈 뒤 네 가지를 물었고 둘은 풀렸다.
다른 인스턴스로 요청해도 되는가 : 된다
재시작 후 로그인 유지 : 된다
이 두 항목은 B-2 의 검증 표에 적힌 판정을 옮긴 것이다. 증거 원문 다섯 개(배포 로그, 스키마, 평문 토큰, 덮어쓰기 시험, 로그아웃 정리) 어디에도 교차 인스턴스 요청이나 재시작 뒤 로그인을 확인한 출력이 없다. 표의 판정까지가 이 결정이 댈 수 있는 근거다.
나머지 둘은 저장소를 나눠도 풀리지 않았다.
같은 사용자의 다른 브라우저가 덮어쓰는가 : 덮어쓴다
로그아웃하면 두 저장소가 다 정리되는가 : 한쪽만
셋째와 넷째의 뿌리는 저장소 선택이 아니라 기본키 한 줄이다. JdbcOAuth2AuthorizedClientService 의 기본 스키마는 기본키를 이렇게 만든다.
PRIMARY KEY (client_registration_id, principal_name)
세션 id 가 이 키에 없어서 같은 사용자의 두 세션이 같은 행을 쓴다. 다른 브라우저로 다시 로그인하면 행이 늘지 않고 값만 바뀐다. 로그아웃한 뒤에는 Redis 세션이 0 키로 정리되고 PostgreSQL 토큰은 1 행으로 살아 있다. 그 행의 refresh token 은 평문이고, bytea 값을 디코드해 JWT 임을 확인했다.
저장소를 Redis 로 골랐든 PostgreSQL 로 골랐든 이 키가 같으면 결과도 같다. 이 결정은 두 문제를 안고 간다.
아직 하지 않은 것도 적어 둔다. 이 실험대는 원인을 기본키로 지목한 데서 멈췄고 스키마를 고쳐 본 적이 없다. 키를 바꾸면 덮어쓰기와 로그아웃 정리가 풀리는지, 평문 토큰을 어떻게 처리할지는 재 보지 않았다.
@@ -0,0 +1,99 @@
---
kind: REFERENCE
slug: look-at-the-lookup-key-before-moving-the-store
title: 저장소를 옮기기 전에 조회 키를 본다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
source:
- final/document.md#선택이-코드와-흐름에-반영되는-방식-b0
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-열린-질문-네-개에-대한-답
evidence:
- ../../../final/evidence/raw/b0-bff-redis-deploy__03-beans-analysis.txt
- ../../../final/evidence/raw/b1-redis-session-store__02-autoconfig-after.txt
- ../../../final/evidence/raw/b1-redis-session-store__03-redis-contents.txt
- ../../../final/evidence/raw/b2-multi-instance-session__02-schema.txt
- ../../../final/evidence/raw/b2-multi-instance-session__04-overwrite-test.txt
- ../../../final/evidence/raw/b2-multi-instance-session__05-logout-cleanup.txt
---
# 저장소를 옮기기 전에 조회 키를 본다
서버가 들고 있던 상태를 외부 저장소로 옮기기 전에 무엇으로 조회하는지부터 읽는다. BFF 에서 세션은 세션 id 로, 인가된 클라이언트는 principal 이름으로 찾았고, 세션만 Redis 로 옮기자 토큰은 따라오지 않았다.
## 관계
- **세션과 인가된 클라이언트는 조회 키가 다르다**
이 기준이 선 근거. 두 저장소가 무엇을 담고 무엇으로 찾는지 설명한다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
조회 키를 그대로 둔 채 저장소만 옮겼을 때 무엇이 남았는지 잰 기록이다.
## 목적
BFF(Backend For Frontend, 브라우저 앞에 두는 서버)는 로그인한 사용자가 누구인지와 그 사용자의 access token, refresh token 을 둘 다 서버에 들고 있다. 두 값은 한 요청에서 같이 쓰이지만 조회 키가 다르다. 세션은 세션 id 로 찾고, 인가된 클라이언트(OAuth2AuthorizedClient)는 principal 이름으로 찾는다.
이 기준은 저장소 한쪽만 밖으로 옮겨 놓고 나머지도 따라왔다고 여기는 실수를 막는다. 세션 저장소를 Redis 로 바꾸는 설정은 세션 id 로 찾는 것만 옮기고, principal 이름으로 찾는 인가된 클라이언트는 그 설정에 걸리지 않는다.
옮긴 뒤에 남은 문제도 저장소 종류가 아니라 조회 키에서 나왔다. 토큰을 PostgreSQL 로 옮긴 뒤에도 테이블의 기본키에 세션 id 가 없어서, 같은 사용자의 두 세션이 같은 행을 쓰고 나중 로그인이 앞의 토큰을 덮어썼다.
## 규칙
### 1. 저장소를 붙이기 전에 자동구성이 무엇을 골랐는지 읽는다
설정하지 않은 값에도 구현체가 하나씩 들어가 있다. 저장소를 붙이기 전 BFF 를 들여다보니 인가된 클라이언트 서비스는 InMemoryOAuth2AuthorizedClientService 였고 저장소는 AuthenticatedPrincipalOAuth2AuthorizedClientRepository 였다. 세션 저장소는 고른 것이 없어 서블릿 컨테이너 메모리에서 돌고 있었고, Redis 도 Spring Session 도 구성되지 않은 상태였다.
두 번째 줄이 조회 키를 정한다. AuthenticatedPrincipalOAuth2AuthorizedClientRepository 는 principal 이름으로 찾기 때문에 조회 키에 세션 id 가 없다.
### 2. 상태마다 무엇으로 찾는지 적고, 키가 다르면 저장소도 따로 정한다
BFF 가 서버에 들고 있는 것은 둘이다. Application Session 은 누가 로그인했는지를 담고 세션 id 로 찾는다. OAuth2AuthorizedClient 는 access token 과 refresh token 을 담고 principal 이름으로 찾는다. 이름은 비슷해도 서로 다른 것을 저장하는 두 개가 따로 굴러간다.
키가 다르면 한쪽을 옮기는 설정이 다른 쪽에 닿지 않는다. 저장소를 하나 골라 둘을 함께 옮기는 대신 상태마다 따로 정한다.
### 3. 한쪽을 옮긴 뒤 나머지 빈이 그대로인지 다시 읽는다
SPRING_SESSION_STORE_TYPE=redis 로 세션을 Redis 로 옮긴 뒤 1번과 같은 방법으로 빈을 다시 읽었더니, 인가된 클라이언트를 다루는 서비스와 저장소와 매니저 셋 다 옮기기 전과 같았다. Redis 에 생긴 키는 하나였고 타입은 hash 였으며, 담긴 필드 일곱 개 어느 이름도 access token 이나 refresh token 을 가리키지 않았다.
설정을 넣은 것과 그 설정이 무엇을 옮겼는지는 따로 확인한다.
### 4. 조회 키가 그대로면 저장소를 옮겨도 키 충돌은 남는다
토큰을 JdbcOAuth2AuthorizedClientService 로 PostgreSQL 에 옮긴 뒤, 같은 사용자가 다른 브라우저로 로그인하자 두 번째 로그인이 첫 토큰을 덮어썼다. 만들어진 테이블의 기본키는 client_registration_id 와 principal_name 두 컬럼이고 세션 id 가 없어서, 같은 사용자의 두 세션이 같은 행을 쓴다.
기본키에 세션 id 를 더하면 덮어쓰기가 사라지는지는 이 실험대에서 재지 않았다.
### 5. 저장소가 둘이면 정리 경로도 둘인지 확인한다
로그아웃한 뒤 두 저장소를 열어 보니 Redis 세션은 0 키로 정리됐고 PostgreSQL 토큰은 1 행이 남았다. 남은 행에는 평문 refresh token 이 그대로 있었다.
세어 보니 지울 것이 둘이 아니라 셋이었다. Keycloak 쪽 SSO 세션이 2 로 남아 있었다. HttpSession 은 Spring Security 가 지우고, 인가된 클라이언트는 아무도 안 지우고, IdP 세션은 RP-initiated logout 을 보내야 끊긴다. 그래서 로그아웃한 뒤 같은 주소를 다시 열면 로그인 화면 없이 그냥 들어가진다.
### 6. 이름을 본 것과 값을 연 것을 나눠 적는다
Redis 해시에서 값까지 꺼내 본 필드는 sessionAttr 로 시작하는 SPRING_SECURITY_CONTEXT 하나이고, 그 값은 \xac\xed 두 바이트로 시작한다 — Java 기본 직렬화의 매직 넘버다. 이름에 OAuth2 가 들어간 …AUTHORIZATION_REQUEST 는 값 안을 열어 보지 않았다.
그래서 확인한 범위는 일곱 필드의 이름까지다. 토큰이 저장되지 않았다고 적으려면 값을 열지 않은 필드를 한 번 더 봐야 한다.
## 적용 조건
- 인스턴스가 둘 이상일 때
- 한 요청이 서버에 든 상태를 둘 이상 쓸 때. BFF 에서는 세션과 인가된 클라이언트가 그렇다
- 메모리에서만 돌던 상태를 Redis 나 PostgreSQL 같은 외부 저장소로 옮기려 할 때
- 자동구성이 고른 구현체를 그대로 쓰고 있을 때
## 예외
- 확인한 짝은 세션(세션 id)과 인가된 클라이언트(principal 이름) 하나뿐이다. 다른 상태 짝에서도 조회 키가 이렇게 갈리는지는 재지 않았다. 이 기준은 다른 짝에서 무엇이 나올지 미리 말하지 않고, 옮기기 전에 같은 확인을 한 번 하라고만 한다
- 상태가 하나뿐이고 조회 키도 하나면 이 확인이 필요 없다. 다만 자동구성이 무엇을 골랐는지는 그때도 읽는다
- 단일 인스턴스로만 운영하면 이 기준이 막으려는 문제가 생기지 않는다
- 기본키를 고쳐서 덮어쓰기를 막는 방법은 이 기준에 없다. 원인을 확인한 데까지이고 고친 뒤를 재지 않았다
## 예시
- 저장소를 붙이기 전 BFF 에는 세션 저장소로 고른 것이 없었고 서블릿 컨테이너 메모리에서 돌고 있었다
- 세션을 Redis 로 옮긴 뒤에도 인가된 클라이언트를 다루는 빈 셋은 옮기기 전과 같았다
- Redis 해시의 필드 일곱 개 어느 이름도 access token 이나 refresh token 을 가리키지 않았다
- 토큰 테이블의 기본키가 client_registration_id 와 principal_name 이라 같은 사용자의 두 번째 로그인이 첫 토큰을 덮어썼다
- 로그아웃한 뒤 Redis 세션은 0 키였고 PostgreSQL 토큰은 1 행이 남았다
@@ -0,0 +1,761 @@
---
id: 4ef91d43-1f1d-481a-8b6f-5e015d068578
kind: SETUP
slug: reproduce-b0-default-session-store
title: 아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/4ef91d43-1f1d-481a-8b6f-5e015d068578/edit"
pinnedVersions:
- name: keycloak-pattern-bff
version: lab
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-0
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다
저장소를 하나도 붙이지 않은 BFF 를 두 노드에 배포하고 `/actuator/beans` 로 Spring 이 무엇을 골랐는지 찍어 보는 절차다. B층 뒤의 여덟 편이 이 배포 위에 서므로 Redis 는 띄우기만 하고 연결하지 않는다. 전 구간 약 40~60분이고 빌드 시간이 들어 있다.
## 관계
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
이 절차가 뽑아 둔 빈 세 개의 이름이 그 기록에서 after 와 견주는 값이 된다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
- **세션과 인가된 클라이언트는 조회 키가 다르다**
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 라는 이름 하나가 왜 이 층 전체의 문제인지를 그 기록이 설명한다.
- **저장소를 옮기기 전에 조회 키를 본다**
여기서 빈 이름을 먼저 찍는 순서를 규칙으로 굳힌 기록이다.
- **Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다**
바로 다음 편이다. 이 절차가 만든 배포에 Redis 를 연결하고 같은 명령을 다시 친다.
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
여기서 이름으로 짐작한 조회 키가 그 편에서 테이블 정의로 확정된다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령이 두 기계에 나뉜다. 소스를 고치고 이미지를 만드는 일은 워크스테이션에서 하고, 클러스터를 보고 배포하는 일은 `kc-lab-1` 에서 `kubectl``kcadm` 으로 한다. 그래서 코드블록마다 어디서 치는지를 붙여 두었다.
**시작 전에 셋을 스스로 정해 둔다. 그 명령이 원 가이드에 없다(unknown).**
첫째, 워크스테이션에서 `kc-lab-1` 로 건너가는 명령이 이 절차에 없다. 라벨은 `[워크스테이션]``[kc-lab-1]` 을 여섯 번 오가는데 `ssh` 로 들어가는 줄도 `exit` 도 안 나온다. 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다 — `ssh test-server "ssh kc-lab-1 '...'"`, 워크스테이션에서 `kc-lab-1` 까지 `test-server` 를 거친다. `[워크스테이션]` 블록은 처음 시작한 셸에서 치고 `[kc-lab-1]` 블록은 그 기계에 붙은 셸에서 친다.
둘째, 이 절차의 파일 경로가 전부 저장소 상대경로다 — `bff/pom.xml`, `deploy/lab/k8s/bff-redis.yaml`. 어느 디렉터리에서 치는지 정하는 줄이 없으므로 두 기계 각각에서 저장소 루트로 먼저 옮겨 두고 시작한다. 다른 디렉터리에서 치면 `kubectl apply` 가 경로를 못 찾고 끝난다.
셋째, 워크스테이션에서 고친 파일을 `kc-lab-1` 로 옮기는 단계가 없다. `vim deploy/lab/k8s/bff-redis.yaml``[워크스테이션]` 이고 그 파일을 읽는 `kubectl apply -f deploy/lab/k8s/bff-redis.yaml``[kc-lab-1]` 인데, 사이에 파일을 넘기는 명령이 원 가이드에 없다. **안 옮기고 치면 오류가 안 난다** — 손 안 댄 매니페스트가 그대로 적용돼 `SPRING_SESSION_STORE_TYPE=redis``BFF_DB_*` 가 살아 있는 채로 배포되고, 화면에는 배포 성공만 뜬다. 어긋난 것은 관찰 절의 빈 수가 `321` 이 아닌 다른 숫자로 나올 때 비로소 보인다.
`kubectl``sudo` 를 붙이지 않는다. root 홈에는 `~/.kube/config` 가 없어 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 반입한 B층 아홉 편의 전제 한 줄만 옛 형태로 `sudo kubectl` 을 적고 있고, 본문 명령 블록에는 한 번도 쓰지 않는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 고치는 파일 | 넷 — `bff/pom.xml` · `SecurityConfig.java` · `application.yml` · `bff-redis.yaml` |
| 주입 수단 | 편집기로 넷을 되돌린 뒤 다시 빌드해 두 노드에 import |
| 무엇을 찍나 | `/actuator/beans` 의 전체 빈 수와 저장소 관련 빈 이름 |
| 브라우저 | 필요하다. `https://app1.hyeonworks.com/` 이 열려야 한다 |
| 도구 | `jq` 가 이 실험대에 없다. 빈 목록은 `grep` 으로 읽는다 |
| 걸리는 시간 | 약 40~60분. 빌드 시간이 들어 있다 |
## 이 실험이 가르는 것
코드에 저장소를 직접 만드는 빈이 없으면 무엇이 실제로 쓰이는지는 Spring Boot 의 자동구성 결과까지 봐야 알 수 있다. 원 가이드는 그 문장을 그대로 인용해 시작한다.
```text
빈을 직접 만들지 않으면
└─ Spring Boot 가 조건에 따라 고른다
└─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
└─ 돌아가는 인스턴스에 물어봐야 안다
```
추측으로도 답은 나온다. 저장소를 안 붙였으니 메모리겠지, 맞다. 그런데 빈 이름 하나가 B층 전체의 문제를 담고 있고 그 이름은 추측으로 안 나온다. 찍어 봐야 나온다.
절차를 끝까지 밟으면 여섯을 자기 화면에서 보게 된다 — 돌고 있는 인스턴스가 실제로 고른 구현체 이름, Redis 도 Spring Session 도 하나도 구성되지 않은 것, 조회 키에 session ID 가 없다는 것, replica 2 에서 로그인 자체가 실패하는 것, replica 를 1 로 줄이면 로그인이 되는 것, 브라우저에 토큰이 0개인 것.
저장소를 먼저 붙이면 이 실험은 성립하지 않는다. Redis 를 미리 연결하면 잴 것이 없어지므로 **Redis 는 배포만 하고 BFF 에 연결하지 않는다.** 연결은 B-1 에서 한다.
## 전제와 되돌리기
- `05-keycloak` 이 끝나 있다. A층 실험은 안 해도 된다.
- 브라우저가 필요하다. 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다.
- BFF 이미지는 워크스테이션에서 빌드해서 두 노드에 밀어 넣는다.
- `jq` 는 이 실험대에 깔려 있지 않다. 이 절차는 `grep` 으로 읽는다.
B층은 A층과 건드리는 대상이 다르고, 그래서 되돌리기도 다르다. A층은 주입 하나를 되돌리면 끝났는데 여기서는 애플리케이션 소스와 매니페스트를 고치므로 소스를 되돌린 뒤 다시 빌드해 두 노드에 다시 밀어 넣어야 클러스터가 따라온다.
| 무엇 | A층 | B층 |
|---|---|---|
| 무엇을 건드리나 | 클러스터 · 네트워크 · 데이터베이스 | 애플리케이션 소스와 매니페스트 |
| 되돌리기 | 주입을 되돌린다 | `git checkout` 한 뒤 다시 빌드해 두 노드에 다시 밀어 넣는다 |
| 브라우저 | 필요 없다 | B-3 을 뺀 셋은 브라우저가 있어야 한다 |
| 이미지 | 이미 떠 있다 | 레지스트리가 없어 `imagePullPolicy: Never` 다. 두 노드에 각각 import 해야 두 replica 가 다 뜬다 |
저장소의 현재 소스는 이미 B-1 과 B-2 를 거친 뒤 상태다. `bff-redis.yaml` 에는 `SPRING_SESSION_STORE_TYPE=redis` 가 있고 `SecurityConfig` 에는 `JdbcOAuth2AuthorizedClientService` 빈이 있다. 그대로 배포하면 B-2 의 결과를 재게 된다. 어느 브랜치에도 B-0 시점의 파일이 없다고 원 가이드가 적어 두었고, 그래서 주입 절의 첫 단계가 손으로 되돌리는 일이다.
되돌리기는 둘이고 둘 다 시작 전에 읽어 둔다.
```bash label="[워크스테이션] 소스만 되돌린다"
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
```
```bash label="[kc-lab-1] 배포한 것을 통째로 지운다"
kubectl delete -f deploy/lab/k8s/bff-redis.yaml
```
PVC 는 `delete -f` 로 같이 지워진다. Redis 데이터도 함께 사라진다.
## 주입 전에 같은 명령으로 먼저 본다
넓은 것부터 좁혀 간다.
```text
노드 자원 → 네임스페이스에 무엇이 있나 → Keycloak realm → 사용자
```
### 1. 노드에 BFF 두 개가 들어갈 자원이 있는가
**무엇을 보는가** — 두 노드의 메모리와 CPU 여유.
```bash label="[kc-lab-1] 메모리와 노드 사용률을 본다"
free -m
kubectl top nodes
```
**어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`).
```text
=== 배포 전 자원 ===
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` 이거나 메모리 부족으로 죽는데, 그때 증상을 Spring 설정 문제로 읽게 된다.
### 2. 네임스페이스에 앞 실험의 잔재가 있는가
**무엇을 보는가** — 지금 무엇이 떠 있는지.
```bash label="[kc-lab-1] ① 워크로드를 본다"
kubectl -n keycloak-lab get all
```
```bash label="[kc-lab-1] ② Secret 과 Ingress 는 따로 본다"
kubectl -n keycloak-lab get secret,ingress
```
**어디를 보나** — `keycloak` StatefulSet 과 `postgres` 가 있고 `bff` 와 `redis` 는 없어야 한다.
**이 값이 뜻하는 것** — `bff` 나 `redis` 가 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면 내가 만든 것과 원래 있던 것이 섞인다. 줄을 둘로 나눈 까닭은 `get all` 이 워크로드 계열만 보여 주기 때문이다. Secret 과 PVC 와 Ingress 는 거기 안 나온다.
### 3. realm 과 클라이언트를 만든다
**목적** — BFF 가 로그인을 보낼 Keycloak realm 과 클라이언트를 세운다. realm 이 없으면 배포는 성공하는데 로그인에서 막힌다.
관리 자격증명을 잡는다. `kcadm` 은 Keycloak 이미지 안에 있다.
```bash label="[kc-lab-1] ① kcadm 에 로그인한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 길이만 센다.
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
realm 과 클라이언트를 만든다.
```bash label="[kc-lab-1] ③ realm 과 confidential 클라이언트를 만든다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
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/*"]'
```
`-s secret=bff-lab-secret` 은 Keycloak 쪽에 저장되는 값이고, BFF 가 실제로 보내는 값은 배포 매니페스트가 만드는 Secret `bff-secrets` 의 `KEYCLOAK_CLIENT_SECRET` 이다. 둘이 같아야 로그인이 끝까지 간다. 이 절차에는 둘을 견주는 단계가 없으므로, 주입 절에서 `deploy/lab/k8s/bff-redis.yaml` 을 열었을 때 그 칸을 눈으로 확인한다.
만들어진 값을 되읽는다.
```bash label="[kc-lab-1] ④ realm 설정 세 칸만 뽑아 본다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan
```
**예상 결과** — 비밀번호 길이는 `19` 다(observed). realm 조회는 `realm` 과 `enabled` 와 `accessTokenLifespan` 세 칸만 돌려준다.
**왜 필요한가** — `accessTokenLifespan=60` 은 B-3 을 위해 미리 짧게 잡는 값이다. 만료를 기다리는 시간이 짧아야 refresh 경쟁이 재현되고, 여기서 정해 두면 나중에 realm 을 다시 안 만든다.
**문제가 생기면** — `kcadm` 이 `401` 이면 `config credentials` 를 안 했거나 세션이 만료된 것이므로 ① 부터 다시 친다.
### 4. 로그인할 사용자를 만든다
**목적** — 브라우저에서 실제로 로그인할 계정을 하나 둔다.
사용자를 만든다.
```bash label="[kc-lab-1] ① 사용자를 만든다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create users -r keycloak-patterns -s username=labuser -s enabled=true
```
비밀번호를 준다.
```bash label="[kc-lab-1] ② 비밀번호를 준다"
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'
```
**★ 뒤의 편들이 이 값을 그대로 쓴다.** 이 실험대는 `labpass` 를 썼고, B-3 과 B-6 의 토큰
요청이 `-d password=labpass` 로 그 값을 박아 놓고 있다. **여기서 다른 값을 정했으면 그
자리들도 같이 바꿔야 한다** — 안 바꾸면 B-3 의 첫 토큰 요청이 `401` 로 떨어지고, 그것이
주입이 안 걸린 것처럼 보인다.
**예상 결과** — 두 명령 다 조용히 끝난다.
**왜 필요한가** — 이 비밀번호는 브라우저에 직접 칠 값이므로 따라 하는 사람이 정한다. 위 값은 예시이고, 실제로 쓸 값을 셸 히스토리에 안 남기려면 `kcadm.sh` 를 대화식으로 쓰거나 나중에 관리 콘솔에서 바꾼다고 원 가이드가 적는다.
**문제가 생기면** — realm 을 통째로 지우면 이 단계가 만든 것이 함께 사라진다.
```bash label="[kc-lab-1] realm 을 통째로 지운다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
```
## 주입
주입은 둘이다. 첫째가 소스를 B-0 시점으로 되돌리는 일이고 둘째가 배포다.
### 1. 파일 넷을 편집기로 열어 B-1·B-2 가 넣은 것을 뺀다
**목적** — 자동구성이 고를 기회를 만든다. 빈을 직접 만들어 두면 무엇을 골랐는지 재는 실험이 성립하지 않는다.
무엇을 왜 지우는지 읽으면서 고쳐야 하는 파일이라 넷 다 편집기로 연다.
의존성을 뺀다.
```bash label="[워크스테이션] ① 빌드 파일을 연다"
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` 용 |
명시 빈을 지운다.
```bash label="[워크스테이션] ② 보안 설정을 연다"
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/**` 를 열어 주는 것이 그 안에 있고, 없으면 관찰 절이 전부 로그인 페이지를 받는다.
설정 블록을 뺀다.
```bash label="[워크스테이션] ③ 애플리케이션 설정을 연다"
vim bff/src/main/resources/application.yml
```
| 지울 블록 | 왜 |
|---|---|
| `spring.session` | `store-type` 기본값이 `redis` 다. 남겨 두면 의존성만 빼도 경고가 난다 |
| `spring.data.redis` | Redis 연결 설정 |
| `spring.datasource` · `spring.sql.init` | B-2 의 JDBC 용 |
매니페스트에서 환경변수 여섯을 뺀다.
```bash label="[워크스테이션] ④ 배포 매니페스트를 연다"
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 label="[워크스테이션] ⑤ 바뀐 파일과 실제 diff 를 본다"
git diff --stat
git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
```
**예상 결과** — `git diff --stat` 에 위 네 파일만 나온다.
**왜 필요한가** — 하나라도 덜 지우면 그 위에서 잰 빈 목록이 B-1 이나 B-2 의 것이 된다. 관찰 절의 `321` 이 다른 숫자로 나오면 여기로 돌아온다.
**문제가 생기면** — `git diff --stat` 에 다섯 번째 파일이 보이면 다른 실험의 변경이 섞인 것이므로 그 파일만 `git checkout` 으로 되돌린다.
### 2. 이미지를 빌드해 두 노드에 각각 밀어 넣는다
**목적** — 고친 소스를 두 노드가 다 쓸 수 있는 이미지로 만든다.
빌드 로그를 파일로 받는다.
```bash label="[워크스테이션] ① 전체 로그를 파일로 받는다"
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
```
실패했으면 로그에서 원인 줄만 뽑는다.
```bash label="[워크스테이션] ② 테스트 결과와 예외만 골라 본다"
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
```
이미지를 두 노드에 각각 넣는다. 레지스트리가 없으므로 한 노드에만 넣으면 나머지 replica 가 안 뜬다.
```bash label="[워크스테이션] ③ 이 실험대가 실제로 친 형태다"
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 label="[kc-lab-1] ④ 두 노드의 이미지 목록을 본다"
sudo k3s ctr images ls | grep keycloak-pattern-bff
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
```
**예상 결과** — 빌드가 성공하면 `exit=0` 이고, ④ 는 두 노드 모두에서 `keycloak-pattern-bff:lab` 줄을 낸다. 원래 실행이 여기서 만난 빌드 실패는 이렇게 보였다(observed).
```text
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
```
`management:` 아래에 `endpoint:` 블록을 하나 더 넣어서 난 오류다. 이미 있는데 또 넣었다. `yamllint` 는 이 실험대에 없고 YAML 중복 키는 빌드가 잡아 주는데, 그 메시지를 보려면 위처럼 전체 로그를 받아야 한다.
**왜 필요한가** — `docker build` 기본 출력은 마지막 몇 줄만 보여 주고 Maven 스택트레이스는 그 위에 있다. 그래서 `--progress=plain` 과 파일로 받는 것을 함께 쓴다. ③ 의 두 줄은 한 줄에 `ssh` 가 두 겹이고 원격 셸의 인용이 겹쳐 있어 따라 하는 사람이 나눠 치고 싶어지는데, 원 가이드가 나눈 형태를 적어 두지 않아 여기에도 없다(unknown). 없는 명령을 지어내지 않는다.
**문제가 생기면** — 이미지가 한쪽에만 있으면 그 노드에 스케줄된 replica 만 뜨고 나머지는 `ErrImageNeverPull` 로 나타난다. ③ 의 두 줄 중 빠진 쪽을 다시 친다.
### 3. 배포한다
**목적** — Redis 와 BFF 를 올린다. Redis 는 올리기만 하고 BFF 에 연결하지 않는다.
매니페스트를 적용하고 롤아웃이 끝날 때까지 기다린다.
```bash label="[kc-lab-1] ① 적용하고 두 롤아웃을 기다린다"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**예상 결과** — 실측은 이렇다(observed, `01-deploy.txt`).
```text
=== 배포 ===
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
```
배포된 모양은 이렇다.
```text
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
├──▶ Keycloak (realm: keycloak-patterns)
└──▶ echo (resource server 대역)
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
```
**왜 필요한가** — 브라우저가 가는 주소와 BFF 가 서버끼리 부르는 주소를 나눠 둔 것도 이 매니페스트다.
```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 이 거부한다.
**문제가 생기면** — 롤아웃이 타임아웃으로 끝나면 `describe pod` 의 Events 를 본다. `ErrImageNeverPull` 이면 이미지 import 로 돌아가고 `Pending` 이면 노드 메모리를 본다.
## 주입 검증
결과를 읽기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다.
### 파드 두 개가 서로 다른 노드에 떴는가
```bash label="[kc-lab-1] BFF 와 Redis 의 배치를 본다"
kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab get pods -o wide -l app=redis
```
실측은 이렇다(observed, `01-deploy.txt`).
```text
bff-574c6d658b-8cz4x true kc-lab-1
bff-574c6d658b-zpkbp true kc-lab-2
redis-568bd7c4-5c5vc true kc-lab-2
```
BFF 두 개가 서로 다른 노드에 있어야 한다. `topologySpreadConstraints` 가 그 일을 한다. 다른 인스턴스가 진짜 다른 기계여야 이 층의 질문이 성립하고, 같은 노드의 다른 프로세스면 재는 값이 절반만 뜻을 갖는다.
### 외부 진입점이 이 애플리케이션의 HTML 을 주는가
```bash label="[kc-lab-1] 상태 줄과 헤더를 읽는 형태로 친다"
curl -I https://app1.hyeonworks.com/
```
실측은 `https://app1.hyeonworks.com/ HTTP 200` 이고(observed, `02-autoconfiguration.txt`), 응답 머리는 이렇게 생겼다.
```text
HTTP/2 200
content-type: text/html
```
상태 줄과 `content-type` 을 같이 본다. `200` 이 왔다고 그게 이 애플리케이션의 HTML 이라는 보장이 없다. 원래 실행은 `/actuator/beans` 를 불렀을 때 `200` 을 받았는데 내용은 Keycloak 로그인 페이지였다. `-L` 로 리다이렉트를 따라간 결과다. `-o /dev/null -w '%{http_code}'` 만 쓰면 그 차이가 안 보이므로 여기서는 읽는 형태인 `-I` 를 쓰고, 여러 번 재서 비교할 때만 뽑는 형태로 바꾼다.
## 관찰
### 1. 빈 목록을 파드 안에서 받는다
**무엇을 보는가** — 자동구성이 실제로 만든 빈 전부.
`/actuator/beans` 는 117KB 이고 nginx 와 Traefik 을 거치면서 실패한다(observed, 해설 문서 1절).
```text
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
```
그래서 파드 안에서 직접 받는다. alpine 기반 JRE 이미지에는 `wget` 이 들어 있어서 Keycloak 이미지와 달리 파드 안에서 HTTP 요청을 보낼 수 있다.
```bash label="[kc-lab-1] ① 받을 파드 이름을 잡는다"
kubectl -n keycloak-lab get pods -l app=bff
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
echo "$BFF"
```
```bash label="[kc-lab-1] ② 파드 안에서 받아 크기를 센다"
kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
wc -c /tmp/beans.json
```
**어디를 보나** — 크기가 10만 바이트 대여야 한다. 모양은 이렇다(observed).
```text
119552 /tmp/beans.json
```
`0` 이면 못 받은 것이고 몇 백 바이트면 로그인 페이지나 오류 본문이다. 앞부분을 열어 확정한다.
```bash label="[kc-lab-1] ③ 앞 200바이트만 본다"
head -c 200 /tmp/beans.json ; echo
```
```json
{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework
```
**이 값이 뜻하는 것** — `{"contexts":{"keycloak-bff"` 로 시작해야 한다. `<!DOCTYPE html` 로 시작하면 Keycloak 로그인 페이지를 받은 것이므로 `bffSecurity` 빈을 지우지 않았는지 본다.
### 2. 빈을 세고 저장소 관련 이름을 뽑는다
**무엇을 보는가** — 전체 빈 수와 저장소 계열 빈의 구현체 이름.
빈 하나는 이런 모양이고 이름과 타입이 한 덩어리 안에 같이 있다.
```text
"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}
```
이 실험대는 `jq` 가 없어 `grep` 으로 덩어리를 뽑았다. 원 가이드가 아래 두 줄을 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ① 빈의 개수를 센다"
grep -o '"aliases":\[' /tmp/beans.json | wc -l
```
```bash label="[kc-lab-1] ② 이름과 타입을 한 줄로 뽑아 거른다"
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
```
`jq` 가 깔려 있으면 그것을 쓴다고 원 가이드가 적는데 어떤 표현을 쓰라고는 적지 않아 그 형태는 여기에도 없다(unknown). 없는 도구를 전제로 한 명령은 진단 도중에 패키지를 깔러 나가게 만든다.
**어디를 보나** — ② 의 출력은 이렇게 생겼다(observed).
```text
"authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager
"authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository
"authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService
```
정리한 실측은 이렇다(observed, `03-beans-analysis.txt`).
```text
컨텍스트: keycloak-bff
전체 빈 수: 321
```
```text
--- 세션 · 토큰 저장소 관련 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
clientRegistrationRepository -> InMemoryClientRegistrationRepository
--- Redis / Spring Session 이 구성되었는가 ---
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
```
없다는 것은 세어서 확인한다.
```bash label="[kc-lab-1] ③ Redis · Spring Session 계열 빈을 센다"
grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json
```
`0` 이 나온다(observed). 의존성 자체가 없으니 자동구성이 걸릴 조건이 없다. 세션은 서블릿 컨테이너인 Tomcat 의 기본 `StandardSession` 에 있고 그것이 인스턴스 메모리다.
**이 값이 뜻하는 것** — 다섯 줄을 하나씩 읽으면 이렇다.
| 빈 | 구현체 | 뜻 |
|---|---|---|
| `authorizedClientService` | `InMemoryOAuth2AuthorizedClientService` | 프로세스 메모리. 재시작하면 사라진다 |
| `authorizedClientRepository` | `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` | principal 기준 조회. session ID 가 없다 |
| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | service 쪽을 쓴다 |
| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 |
| SessionRepository | 없음 | Tomcat 의 기본 `StandardSession` |
| Redis · Spring Session | 없음 | 의존성 자체가 없다 |
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 이름이 곧 설명이다. 인증된 요청이면 `OAuth2AuthorizedClientService` 에 위임하고, 그 서비스가 쓰는 키에 session ID 가 없다.
```text
요청이 인증되어 있으면
└─▶ OAuth2AuthorizedClientService 에 위임
└─▶ 키: (clientRegistrationId, principalName)
└─ session ID 가 없다 ★
인증되어 있지 않으면
└─▶ HttpSession 에 임시 보관
```
같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을 보고, 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다. Redis 를 붙여도 이건 안 고쳐진다. 저장소를 공유해도 키에 session ID 가 없기 때문이다. 메모리에 있겠거니 하는 데까지는 추측으로 맞혀도, 조회 키가 무엇인지는 빈 이름을 봐야 안다.
### 3. 브라우저로 로그인해 본다
**무엇을 보는가** — replica 2 에서 로그인이 되는지. 원 가이드가 예상 못 한 것으로 적어 둔 부분이다.
브라우저에서 `https://app1.hyeonworks.com/` 을 열고 로그인한다.
**어디를 보나** — 주소창이 이렇게 끝난다(observed).
```text
https://app1.hyeonworks.com/login?error
```
로그를 본다.
```bash label="[kc-lab-1] 두 replica 의 로그를 접두사와 함께 본다"
kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix
```
아무 오류도 없다. Spring Security 는 로그인 실패를 DEBUG 로만 남긴다. 로그에 아무것도 없으니 애플리케이션 문제가 아니라고 읽으면 틀린다. 증상은 있는데 로그가 없고, 그럴 때는 가설을 세워 시험한다.
**이 값이 뜻하는 것** — 인가 코드 흐름은 왕복이 두 번이고 두 번 다 같은 인스턴스로 가야 한다.
```text
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
```
저장 위치가 `HttpSession` 이고 그것이 인스턴스 메모리이므로 콜백이 다른 replica 로 가면 저장된 인가 요청이 없어 실패한다. 앞에서 본 SessionRepository 없음이 이 가설의 근거다.
### 4. replica 를 1 로 줄여 가설을 시험한다
**목적** — 왕복 두 번이 같은 인스턴스로 가게 만들어 가설을 가른다.
replica 를 하나로 줄인다.
```bash label="[kc-lab-1] ① replica 를 1 로 줄인다"
kubectl -n keycloak-lab scale deployment/bff --replicas=1
kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
kubectl -n keycloak-lab get pods -l app=bff
```
브라우저에서 쿠키를 먼저 지우고 다시 로그인한다.
**예상 결과** — 실측은 이렇다(observed, `b0-bff-login-success-single-replica.png`).
```text
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
replica 1 → 로그인 성공
```
**왜 필요한가** — 가설이 확정된다. 다중 인스턴스에서 어떻게 운영할 것인가는 로그인한 뒤의 문제가 아니라 로그인 자체의 문제이고, B-2 의 검증 1번인 한쪽에서 로그인한 뒤 다른 인스턴스로 요청하기보다 앞선 단계다. 로그인이 끝나야 그 검증을 하는데 로그인부터 막힌다.
**문제가 생기면** — replica 1 에서도 `/login?error` 가 뜨면 쿠키를 안 지우고 다시 로그인했다. 앞선 실패의 세션이 섞이면 이 시험이 가르는 것이 없어진다.
### 5. 토큰 경계를 읽는다
**무엇을 보는가** — 브라우저와 서버 중 어느 쪽이 토큰을 들고 있는지.
브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
**어디를 보나** — 실측은 이렇다(observed, `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` | 브라우저에는 토큰이 하나도 없다 |
**이 값이 뜻하는 것** — 브라우저는 세션 쿠키만 들고 있고 토큰은 전부 서버에 있다. 이 세 값을 적어 둬야 B-1 에서 무엇이 바뀌는지 읽을 수 있다.
## 복구와 원상복구 확인표
### 1. replica 를 되돌린다
```bash label="[kc-lab-1] replica 를 2 로 올린다"
kubectl -n keycloak-lab scale deployment/bff --replicas=2
kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
```
B-1 로 이어서 갈 것이라면 배포는 그대로 둔다. 거기서 같은 파드에 Redis 를 붙인다.
### 2. 소스를 되돌린다
```bash label="[워크스테이션] 네 파일을 되돌리고 확인한다"
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 구성이 다시 배포된다. 소스만 되돌리고 끝내면 클러스터에는 여전히 B-0 이미지가 도는데, 다음 편이 곧바로 다시 빌드하므로 B-1 로 이어 갈 때는 재빌드가 그 편의 첫 단계다. 여기서 멈출 것이면 되돌린 소스로 한 번 더 빌드해 두 노드에 다시 import 해야 클러스터가 소스와 같아진다.
### 3. 전부 지운다
**B-1 이나 B-2 로 이어서 갈 것이면 이 절을 치지 않는다.** B-1 은 「Redis 는 배포만 되어 있고 아직 연결되지 않았다. B-0 이 그렇게 만들어 뒀다」를 전제로 시작하고 B-2 는 그 위에서 시작한다. 아래 두 줄은 `bff` 와 `redis` Deployment 를 PVC 까지, realm `keycloak-patterns` 를 `labuser` 까지 한꺼번에 없앤다. 치고 나면 B-1 은 배포와 realm 과 사용자를 다시 만드는 데서 시작해야 하는데 그 순서는 B-1 에 안 적혀 있고 이 편의 주입 절과 realm·사용자 단계로 되돌아와야 한다. B층을 여기서 끝낼 때만 친다.
```bash label="[kc-lab-1] 배포와 realm 을 지운다"
kubectl delete -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
```
아래 확인표는 §1 과 §2 까지만 마친 상태를 본다 — 배포는 살아 있고 replica 가 2이며 소스가 깨끗한 상태다. §3 을 친 뒤에 이 표를 돌리면 `get deploy bff` 가 `NotFound` 를 내고 밖의 `curl -I` 도 `200` 을 못 낸다. 그때는 표가 틀린 것이 아니라 잴 대상이 없어졌으므로, §3 을 쳤으면 표를 건너뛰고 마지막 줄의 임시 파일만 지운다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| replica | `kubectl -n keycloak-lab get deploy bff` | `2/2` |
| 소스 | `git status --short` | 출력 없음 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide -l app=bff` | 두 노드에 하나씩 |
| Keycloak | `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` | — |
마지막 줄의 `rm -f /tmp/beans.json /tmp/build.log` 는 한 줄인데 두 파일이 서로 다른 기계에 있다. `/tmp/beans.json` 은 `kc-lab-1` 에서 만들었고 `/tmp/build.log` 는 워크스테이션에서 만들었으므로 각 기계에서 자기 쪽 파일을 지운다.
actuator 를 열어 둔 채로 두지 않는다. `/actuator/beans` 와 `/actuator/env` 는 내부 구조와 설정값을 그대로 드러낸다. 실험대라서 여는 것이고 운영이라면 `health` 만 남긴다고 원 가이드가 적는다.
## 막히면
원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 빈 목록에 `RedisSessionRepository` 가 있다 | B-1·B-2 배선을 덜 지웠다 | 주입 절의 네 파일을 다시. `git diff` 로 확인 |
| `authorizedClientService` 가 `Jdbc...` 다 | `SecurityConfig` 의 명시 빈을 안 지웠다 | 같은 절의 둘째 파일 |
| `/actuator/beans` 가 `Bad Gateway` | 응답이 117KB 라 프록시가 못 넘긴다 | 파드 안에서 받는다 |
| `/actuator/beans` 가 `200` 인데 HTML | Keycloak 로그인 페이지다 | `head -c 200` 으로 내용 확인 |
| `beans.json` 이 0 바이트 | 파드 이름이 틀렸거나 포트가 다르다 | `get pods -l app=bff`, 포트는 `8083` |
| 빌드가 실패하는데 원인이 안 보인다 | 마지막 15줄에 없다 | `--progress=plain` + 파일 |
| 테스트에서 컨텍스트가 안 뜬다 | 환경변수에 기본값이 없다 | `grep -n 'KC_ISSUER' bff/src/main/resources/application.yml` |
| 파드가 `ErrImageNeverPull` | 그 노드에 이미지가 없다 | 두 노드에 각각 import |
| 파드가 `Pending` | 노드 메모리 부족 | `describe pod` Events, `top nodes` |
| 브라우저가 `/login?error` | replica 2 인데 스티키 세션이 없다 | replica 1 로 줄여 확인 |
| BFF 로그에 오류가 없다 | Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 | 로그 없음을 문제 없음으로 읽지 않는다 |
| 로그인 후 리다이렉트가 `http://` 로 간다 | `SERVER_FORWARD_HEADERS_STRATEGY` 가 없다 | 매니페스트 env 확인 |
| `jq: command not found` | 이 실험대에 `jq` 가 없다 | `grep` 으로 읽는다 |
| `kcadm` 이 `401` | `config credentials` 를 안 했거나 만료됐다 | realm 준비 단계를 다시 |
원래 실행이 겪은 것 가운데 둘은 소스 쪽 사고였다. 하나는 `bff/target/classes/...` 9개 파일만 커밋되어 있고 `bff/src/` 가 없던 상태다. `.gitignore` 에 `target/` 이 없어 클래스 파일만 들어갔고 소스는 다른 브랜치에 있었다. 빌드 산출물이 커밋되어 있으면 빌드는 되는데 소스를 바꿔도 결과가 안 바뀐다.
```bash label="[워크스테이션] ① 소스가 실제로 있는지 본다"
ls bff/src/main/java/com/example/keycloakpattern/bff/
```
```bash label="[워크스테이션] ② 소스가 있는 브랜치에서 가져온다"
git checkout origin/develop-keycloak-pattern3 -- bff/
```
다른 하나는 환경변수에 기본값이 없어 테스트가 죽은 것이다. 테스트는 그 환경변수를 모른다.
```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
```
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 13:3913:46 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 배포 전 노드 자원 `44%` 와 `33%`, 배포 출력 전문, 파드 세 줄과 그 노드 배치, 외부 진입점 `HTTP 200`, 전체 빈 수 `321`, 저장소 관련 빈 다섯 줄과 「★ 없음」, `/actuator/beans` 가 117KB 이고 프록시에서 `Bad Gateway` 인 것, 비밀번호 길이 `19`, `token-boundary` 의 세 값, replica 2 에서 `/login?error` 이고 replica 1 에서 로그인이 되는 것.
- (unknown) 빈을 세는 `grep -o '"aliases":\['` 줄과 이름·타입을 한 줄로 뽑는 `grep`·`sed` 줄. 원 가이드가 미검증으로 표시했다. `jq` 로 같은 것을 읽는 형태와, 두 겹 `ssh` 를 나눠 치는 형태는 가이드에 없다.
- (observed) 파이썬 한 줄로 JSON 을 파싱하려다 난 `SyntaxError` 도 측정 기록에 있다. 그 시도가 깨진 뒤 `grep` 형태로 다시 받았고, 위에 실은 빈 목록이 그 결과다.
- (observed) 빌드 로그의 `processDuplicateKeys` 는 `management:` 아래에 `endpoint:` 를 한 번 더 넣어서 난 것이다. `yamllint` 가 이 실험대에 없어 빌드가 그 오류를 처음 알렸다.
- 이 실험이 재지 않은 것 하나 — 스티키 세션을 켜면 replica 2 에서 로그인이 되는지는 재지 않았다. 같은 인스턴스로 보내면 된다는 것은 추론이고 측정이 아니다.
<!-- body:end -->
@@ -0,0 +1,792 @@
---
id: 1e5d05fa-88f4-4ef5-a402-4a520ae4a52d
kind: SETUP
slug: reproduce-b1-redis-session-store
title: Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/1e5d05fa-88f4-4ef5-a402-4a520ae4a52d/edit"
pinnedVersions:
- name: keycloak-pattern-bff
version: lab
- name: Redis
version: 7.4.x
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-1
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다
Redis 를 세션 저장소로 붙이고 B-0 에서 찍어 둔 빈 목록과 견주는 절차다. 의존성 둘을 함께 넣어 다시 빌드하고 두 노드에 밀어 넣은 뒤, 무엇이 옮겨졌는지와 무엇이 안 옮겨졌는지를 같은 명령으로 확인한다. 전 구간 약 40분이고 빌드 시간이 들어 있다.
## 관계
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
이 절차가 낸 결과를 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
의존성을 하나만 넣으면 오류 없이 메모리에 남는다. 그 아홉 건 가운데 하나가 이 편에서 나왔다.
- **세션과 인가된 클라이언트는 조회 키가 다르다**
빈 81개가 늘었는데 인가된 클라이언트 빈 셋이 그대로인 까닭을 그 기록이 설명한다.
- **아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다**
먼저 해 둬야 하는 편이다. 그 편이 남긴 빈 세 개의 이름과 숫자 `321` 이 여기서 대조군이 된다.
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
다음 편이다. 여기서 Redis 로 안 옮겨진 인가된 클라이언트를 그 편이 공유 저장소로 옮긴다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
B-0 과 같다. 소스를 고치고 이미지를 만드는 일은 워크스테이션에서 하고, 클러스터를 보고 배포하는 일은 `kc-lab-1` 에서 친다. `kubectl``sudo` 를 붙이지 않는다. 브라우저 창도 하나 열어 둔다.
**끊기는 곳도 B-0 과 같다. 시작 전에 셋을 스스로 정해 둔다 — 그 명령이 원 가이드에 없다(unknown).**
첫째, 워크스테이션과 `kc-lab-1` 을 여섯 번 오가는데 건너가는 `ssh``exit` 도 이 절차에 없다. 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다 — `ssh test-server "ssh kc-lab-1 '...'"`.
둘째, `bff/pom.xml``deploy/lab/k8s/bff-redis.yaml` 이 전부 저장소 상대경로다. 두 기계 각각에서 저장소 루트로 옮겨 두고 시작한다.
셋째, 워크스테이션에서 고친 `deploy/lab/k8s/bff-redis.yaml``kc-lab-1` 로 넘기는 단계가 없다. 이 편에서는 그 누락이 B-0 보다 더 헷갈리게 나온다 — 주입 3번이 넣는 `enableServiceLinks: false``kc-lab-1` 쪽 파일에 없으면 `apply` 는 성공하는데 파드가 똑같이 `CrashLoopBackOff` 로 남고, 그때 이 편은 「파드가 아직 안 바뀌었다」를 먼저 의심하라고 적는다. 실제로는 고친 파일이 그 기계에 안 간 것이다.
**아래 코드블록의 라벨은 그 줄을 치는 기계를 가리킨다.** `[워크스테이션 → kc-lab-1]` 이 붙은 블록은 한 블록 안에서 기계가 바뀌므로 어느 줄이 어디인지 블록 뒤에 적어 두었다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 고치는 파일 | 넷 — `bff/pom.xml` · `application.yml` · `BffControllerTest.java` · `bff-redis.yaml` |
| 주입 수단 | 의존성 둘을 넣고 다시 빌드해 두 노드에 import |
| 무엇을 찍나 | `/actuator/beans` 의 빈 수와 이름, Redis 의 키·필드·TTL |
| 브라우저 | 필요하다. 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다 |
| 도구 | `jq` 가 이 실험대에 없다. `grep``redis-cli` 로 읽는다 |
| 걸리는 시간 | 약 40분. 빌드 시간이 들어 있다 |
## 이 실험이 가르는 것
B-0 이 답을 냈다. 세션도 토큰도 인스턴스 메모리에 있고, 그래서 replica 2 에서는 로그인조차 안 된다. 처방은 뻔해 보인다 — 공유 저장소를 붙인다.
```text
Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다
정말 그런가?
```
이 실험이 재는 것은 붙였다와 공유된다 사이의 거리다. 묻는 것이 넷이고 그중 둘째를 이 실험이 판정한다.
| | 물어볼 것 |
|---|---|
| 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 |
| 무엇이 안 옮겨졌나 | 같은 곳. 안 바뀐 것을 확인하는 쪽이 더 중요하다 |
| 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 |
| 사용자에게는 어떻게 보이나 | 브라우저 |
절차를 끝까지 밟으면 일곱을 자기 화면에서 보게 된다 — 쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것, 그것이 `enableServiceLinks: false` 로 고쳐지는 것, 빈이 321 에서 402 로 81개 늘어나는 것, 그런데 인가된 클라이언트는 하나도 안 바뀐 것, Redis 안의 키와 필드와 TTL 에 토큰이 없는 것, 세션이 Java 네이티브 직렬화인 것, 로그인은 되어 있는데 아무것도 못 하는 상태.
## 전제와 되돌리기
- B-0 이 끝나 있고, B-0 의 답인 빈 세 개의 이름을 손에 들고 시작한다. 이 실험은 그 값들이 어떻게 바뀌는지를 잰다.
- B-0 의 복구 절 §3 「전부 지운다」를 치지 않은 상태여야 한다. 그 절은 `bff``redis` Deployment 와 realm `keycloak-patterns` 를 함께 없애므로, 쳤으면 아래 첫 확인부터 빈 목록이 나온다. 그 상태라면 B-0 의 주입 절과 realm·사용자 단계를 다시 밟아 배포를 세운 뒤 여기로 온다.
- 브라우저가 필요하다.
- 명령은 `kc-lab-1` 에서 친다.
- `jq` 는 이 실험대에 깔려 있지 않다. 이 절차는 `grep``redis-cli` 로 읽는다.
애플리케이션 구성을 바꾸는 실험이라 의존성과 설정을 고쳐 다시 빌드하고 다시 배포한다. 되돌리려면 소스를 되돌린 뒤 또 한 번 빌드해 두 노드에 다시 밀어 넣어야 하므로 `git status` 가 깨끗한 상태에서 시작한다.
되돌리기는 먼저 읽어 둔다.
```bash label="[워크스테이션] 소스를 되돌린다"
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
deploy/lab/k8s/bff-redis.yaml
```
주입 절의 첫 배포는 일부러 고장 난 상태로 한다. 함정을 직접 보기 위해서이고, 건너뛰고 `enableServiceLinks: false` 부터 시작해도 결과는 같다고 원 가이드가 적는다.
## 주입 전에 같은 명령으로 먼저 본다
넓은 것부터 좁혀 간다.
```text
BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
```
### 1. BFF 두 개와 Redis 가 떠 있는가
**무엇을 보는가** — 파드 배치.
```bash label="[kc-lab-1] BFF 와 Redis 의 배치를 본다"
kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab get pods -o wide -l app=redis
```
**어디를 보나** — 모양은 이렇고 주소와 해시는 환경마다 다르다(observed).
```text
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 이 그렇게 만들어 뒀다.
### 2. B-0 의 답을 before 값으로 다시 잡는다
**무엇을 보는가** — 주입 뒤에 견줄 빈 수와 빈 이름 셋.
```bash label="[kc-lab-1] ① 빈 목록을 파드 안에서 받는다"
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
wc -c /tmp/beans-before.json
```
이 실험대는 `jq` 가 없어 `grep` 으로 덩어리를 뽑았다. 원 가이드가 아래 두 줄을 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ② 빈을 세고 저장소 계열 이름을 뽑는다"
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'
```
`jq` 가 깔려 있으면 그것을 쓴다고 원 가이드가 적는데 어떤 표현을 쓰라고는 여기서도 적지 않았다(unknown).
**어디를 보나** — 실측은 이렇다(observed, `02-autoconfig-after.txt`).
```text
빈 수: 321 → 402 (+81)
```
```text
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
```
**이 값이 뜻하는 것** — 지금 빈 수가 `321` 이고 `sessionRepository` 는 아예 없다. 이 세 줄과 숫자를 적어 둔다. 관찰 절이 이 값과 견주고, 견줄 것이 없으면 안 바뀌었다고 말할 수 없다.
### 3. Redis 가 살아 있고 비어 있는가
**무엇을 보는가** — 연결 여부와 키 개수.
```bash label="[kc-lab-1] ① 응답과 판 번호를 읽는 형태로 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head
```
```bash label="[kc-lab-1] ② 키 개수와 키 이름을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**어디를 보나** — 모양은 이렇다(observed).
```text
PONG
# Server
redis_version:7.4.x
...
```
```text
(integer) 0
```
**이 값이 뜻하는 것** — `0` 이어야 뒤에서 찾은 키를 내가 만든 것이라고 말할 수 있다. `KEYS *` 대신 `--scan` 을 쓰는 까닭은 `KEYS` 가 서버를 블로킹하기 때문이다. 지금은 키가 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.
### 4. 파드 안에 이미 Redis 관련 환경변수가 있는가
**무엇을 보는가** — 아직 아무것도 안 바꿨는데 들어와 있는 값. 이 실험의 함정이 여기서 시작된다.
한 번은 통째로 보고 그다음 걸러 본다.
```bash label="[kc-lab-1] ① 환경변수를 통째로 본다"
kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
```
```bash label="[kc-lab-1] ② Redis 쪽만 거른다"
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
```
**어디를 보나** — 모양은 이렇다(observed).
```text
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 마다 옛 Docker 링크 호환용 환경변수를 파드에 자동으로 넣고, 그 기능이 기본으로 켜져 있다. Service 이름이 `redis` 이므로 `REDIS_*` 가 들어오는데 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. 이름이 겹친다.
```text
Service 이름이 redis 이면
REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
```
매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그쪽이 이긴다. 명시를 안 하면 자동 주입이 이기고, 오류 메시지는 쓰지도 않은 값을 지목한다. `REDIS` 와 `POSTGRES` 와 `MYSQL` 처럼 흔한 Service 이름일수록 위험하다고 원 가이드가 적는다. 지금은 애플리케이션이 그 변수를 안 읽으므로 아무 일도 안 일어나고, 읽기 시작하는 순간 파드가 죽는다.
## 주입
### 1. 의존성 둘을 함께 넣는다
**목적** — `SessionRepository` 를 Redis 로 갈아끼우고 연결을 제공한다. 하나만 넣으면 오류 없이 메모리에 남으므로 둘을 같이 넣는다.
무엇을 왜 넣는지 읽으면서 고쳐야 하는 파일이라 편집기로 연다.
```bash label="[워크스테이션] ① 빌드 파일을 연다"
vim bff/pom.xml
```
```xml
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
둘 다 있어야 자동구성이 걸린다 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
```
설정에 Redis 연결과 세션 저장소를 적는다.
```bash label="[워크스테이션] ② 애플리케이션 설정을 연다"
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 가 없으므로 테스트에서만 저장소를 끈다.
```bash label="[워크스테이션] ③ 테스트를 연다"
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",
})
```
**예상 결과** — 세 파일이 `git diff --stat` 에 나온다.
**왜 필요한가** — `spring-session-data-redis` 를 넣으면 컨텍스트가 뜰 때 Redis 에 붙으려 하고, 테스트에는 Redis 가 없다. `spring.session.store-type=none` 한 줄이 없으면 빌드가 테스트 단계에서 죽는데 그 실패 메시지가 Redis 연결 오류라 배포 환경 문제로 읽히기 쉽다. 실패한 곳은 빌드다.
**문제가 생기면** — 뒤에서 `sessionRepository` 가 안 생기면 의존성을 하나만 넣은 것이므로 `pom.xml` 에 둘 다 있는지부터 본다.
### 2. 매니페스트를 일부러 고장 난 채로 올린다
**목적** — 자동 주입된 `REDIS_PORT` 가 애플리케이션 설정을 어떻게 이기는지 한 번 본다.
```bash label="[워크스테이션] ① 배포 매니페스트를 연다"
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 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다
```
빌드해서 두 노드에 밀어 넣고 배포한다. 아래 네 줄이 이 실험대가 실제로 친 형태다(observed).
```bash label="[워크스테이션 → kc-lab-1] ② 빌드하고 두 노드에 넣고 배포한다"
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 -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff
```
**이 블록은 한 블록인데 기계가 둘이다.** 앞의 네 줄(`docker build` · `echo` · `docker save` 두 줄)은 워크스테이션에서 치고, `kubectl` 로 시작하는 아래 두 줄은 `kc-lab-1` 에서 친다. 워크스테이션에는 kubeconfig 가 없어 거기서 `kubectl` 을 치면 클러스터에 못 붙고 끝난다. 그리고 `kubectl apply` 가 읽는 `deploy/lab/k8s/bff-redis.yaml` 은 방금 워크스테이션에서 고친 그 파일이므로, `kc-lab-1` 쪽 체크아웃에도 같은 내용이 있어야 한다. 옮기는 명령은 원 가이드에 없다(unknown).
가운데 두 줄은 한 줄에 `ssh` 가 두 겹이고 원격 셸의 인용이 겹쳐 있어 나눠 치고 싶어지는데, 원 가이드가 나눈 형태를 적어 두지 않아 여기에도 없다(unknown).
지금 멈추려면 롤아웃을 되돌린다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
kubectl -n keycloak-lab rollout undo deployment/bff
```
**예상 결과** — 파드가 뜨지 않는다. 넓은 것부터 본다.
```bash label="[kc-lab-1] ③ 파드 상태를 본다"
kubectl -n keycloak-lab get pods -l app=bff
```
```text
NAME READY STATUS RESTARTS AGE
bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s
```
로그보다 먼저 이벤트를 보고, 그다음 로그를 본다. 지금 파드와 죽기 전 파드를 따로 본다.
```bash label="[kc-lab-1] ④ 이벤트를 본다"
kubectl -n keycloak-lab describe pod -l app=bff | tail -20
```
```bash label="[kc-lab-1] ⑤ 지금 로그와 죽기 전 로그를 본다"
kubectl -n keycloak-lab logs -l app=bff --tail=40
kubectl -n keycloak-lab logs -l app=bff --previous --tail=40
```
실측은 이렇다(observed, 해설 문서 1절).
```text
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"` 는 매니페스트 어디에도 쓰지 않은 값이다. 주입 전에 `printenv` 로 미리 본 그 환경변수이고 쿠버네티스가 넣었다. Redis 는 멀쩡하고, 파드는 Redis 에 붙어 보지도 못한 채 설정 바인딩에서 죽었다. 메시지가 `Failed to bind properties` 라고 말하고 있다.
```bash label="[kc-lab-1] ⑥ Redis 가 멀쩡한지 따로 확인한다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
```
`PONG` 이 온다(observed).
**문제가 생기면** — 파드가 정상으로 떴다면 매니페스트에 `REDIS_PORT` 를 이미 줬다. 함정을 건너뛰어도 결과는 같으므로 다음 단계로 간다.
### 3. 자동 주입을 끄고 다시 올린다
**목적** — 이름 충돌의 근본을 없앤다.
처방은 둘인데 하나만 근본 처방이다.
| 처방 | 문제 |
|---|---|
| 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | 다음 사람이 같은 함정에 다시 빠진다 |
| 주입 자체를 끈다 | 근본 처방 |
```bash label="[워크스테이션] ① 배포 매니페스트를 다시 연다"
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 label="[kc-lab-1] ② 적용하고 롤아웃을 기다린다"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**예상 결과** — 실측은 이렇다(observed, `01-servicelinks-trap.txt`).
```text
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 의 파드가 아직 종료 전이다. 잠시 뒤 두 개가 된다.
**왜 필요한가** — `enableServiceLinks: false` 는 그 파드에 대해 Service 이름 기반 환경변수 주입 전체를 끈다. 이름 하나를 피해 가는 것과 달라서 다음에 Service 를 하나 더 만들어도 같은 충돌이 안 난다.
**문제가 생기면** — 고쳤는데 오류가 똑같이 나면 파드가 아직 안 바뀌었다. `rollout restart` 뒤에 `printenv` 를 다시 본다.
## 주입 검증
결과를 읽기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 자동 주입이 정말 사라졌는지부터다.
### 1. 자동 주입된 환경변수가 사라졌는가
```bash label="[kc-lab-1] 새 파드 이름을 다시 잡고 환경변수를 본다"
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
```
모양은 이렇다(observed).
```text
REDIS_HOST=redis.keycloak-lab.svc
REDIS_PORT=6379
```
`REDIS_SERVICE_HOST` 계열이 전부 사라졌고 넘겨준 두 개만 남았다. `REDIS_PORT` 가 `6379` 다.
### 2. 자동구성이 실제로 걸렸는가
```bash label="[kc-lab-1] 파드 배치와 헬스를 본다"
kubectl -n keycloak-lab get pods -o wide -l app=bff
kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/health
```
모양은 이렇다(observed).
```json
{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}
```
`redis` 컴포넌트가 있고 `UP` 이다. B-0 에서는 이 컴포넌트가 아예 없었다. `spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔고, 건강 체크에 새 항목이 생긴 것이 자동구성이 걸렸다는 신호다.
### 3. replica 2 에서 로그인이 되는가
브라우저에서 쿠키를 먼저 지우고 `https://app1.hyeonworks.com/` 로 로그인한다.
로그인이 된다(observed, `b1-login-works-two-replicas.png`). B-0 에서 replica 2 로는 `/login?error` 였던 그 부분이다. 인가 요청의 state 와 PKCE verifier 가 이제 Redis 에 있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. B-0 이 replica 를 1 로 줄여야 했던 문제는 고쳐졌다. 원 가이드는 여기에 곧바로 경고를 붙인다 — 여기서 멈추면 Redis 를 붙였더니 다 해결됐다로 끝나고, 그것이 이 실험이 막으려는 결론이다.
## 관찰
### 1. 빈 목록을 다시 찍어 before 와 견준다
**무엇을 보는가** — 늘어난 빈과 안 바뀐 빈.
B-0 의 방법을 그대로 다시 쓴다.
```bash label="[kc-lab-1] ① 빈 목록을 받고 개수를 센다"
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
```
**어디를 보나** — 실측은 이렇다(observed, `02-autoconfig-after.txt`).
```text
빈 수: 321 → 402 (+81)
```
새로 생긴 세션 저장소 빈을 뽑는다. 원 가이드가 아래 줄을 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ② 세션·Redis 계열 빈을 뽑는다"
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -iE 'session|redis'
```
```text
--- 세션 저장소 관련 (새로 생긴 것) ---
★ cookieSerializer -> DefaultCookieSerializer
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ sessionRepository -> RedisSessionRepository
★ springSessionRepositoryFilter -> SessionRepositoryFilter
★ redisConnectionFactory -> LettuceConnectionFactory
★ redisTemplate -> RedisTemplate
```
같은 파일에서 인가된 클라이언트 쪽을 따로 뽑는다. 이 실험이 판정하려는 것이 이쪽이다.
```bash label="[kc-lab-1] ③ authorized client 계열을 뽑는다"
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
```
```text
--- OAuth2 authorized client — 바뀌었는가? ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
```
**이 값이 뜻하는 것** — 빈 81개가 늘었는데 인가된 클라이언트는 하나도 안 바뀌었다.
```text
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token)
```
`spring.session.store-type` 은 `HttpSession` 을 갈아끼우는 설정이고 `OAuth2AuthorizedClient` 는 그 설정과 무관한 다른 저장소에 있다. 찍어서 확인하지 않으면 이 사실을 알 방법이 없다. 로그인은 되고 화면도 뜨고 파드도 건강하다. B-0 을 실험으로 만든 까닭이 여기서 드러난다 — before 가 있어야 after 를 읽는다.
### 2. Redis 를 직접 연다
**무엇을 보는가** — 옮겨진 것 안에 무엇이 들었는지.
```bash label="[kc-lab-1] ① 키 개수와 키 이름을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
실측은 이렇다(observed, `03-redis-contents.txt`).
```text
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
```
네임스페이스가 `bff:session` 이다. `application.yml` 의 `spring.session.redis.namespace` 가 그대로 접두어가 됐다.
키 이름을 변수로 잡는다. 이 줄에는 걸러 내는 조각이 둘 붙어 있다.
```bash label="[kc-lab-1] ② 세션 키 하나를 변수에 담는다"
KEY=$(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 을 달고 오기 때문에 붙인다. 빼면 키가 안 맞는데 오류는 안 난다.
바로 위 `redis-cli --scan` 이 이미 전체 키를 보여 줬으므로 그 출력에서 키 하나를 눈으로 골라 쳐도 되는데, 원 가이드가 그 두 단계 형태를 적어 두지 않았다(unknown).
```bash label="[kc-lab-1] ③ 타입과 필드 이름을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
```
**어디를 보나** — 실측은 이렇다(observed, `03-redis-contents.txt`).
```text
타입: 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 이 평문으로 남는지 확인하는 것이 검증 항목이었는데, 답은 더 앞에 있었다 — 애초에 들어가지 않는다. 토큰 암호화를 어떻게 할지 고민하기 전에 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.
### 3. TTL 과 값의 바이트를 본다
```bash label="[kc-lab-1] ① 남은 수명을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"
```
```text
=== TTL (Q3 검증 3번 — session TTL) ===
TTL: 1772 초
```
`spring.session.timeout=30m` 인 1800초에서 방금 지난 만큼 줄어든 값이다. 세션 TTL 1772초와 access token 수명 60초가 처음부터 어긋나 있다. 어느 쪽에 맞출지 고르기 전에 이미 어긋나 있고, 그 간극을 누가 메우는지가 B-3 의 주제다.
```bash label="[kc-lab-1] ② 값의 앞 네 줄을 이스케이프해서 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4
```
```text
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` 로 시작한다. Java 직렬화 매직 넘버이고 JSON 이 아니다. `--no-raw` 를 쓰는 것은 바이너리를 이스케이프해서 보여 주기 때문이다. 안 쓰면 터미널이 제어문자를 먹고 화면이 깨진다.
| 결과 | |
|---|---|
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
| 클래스 버전에 묶인다 | 애플리케이션을 올리면 기존 세션이 역직렬화에 실패할 수 있다 |
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
D-2 의 버전 업그레이드에서 이 성질이 다시 나온다. Spring Security 버전이 바뀌면 Redis 에 있던 세션이 깨질 수 있다.
### 4. 파드를 전부 교체하고 사용자 화면을 본다
**목적** — 롤링 재시작으로 Redis 덕을 보는지 확인한다. 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.
```bash label="[kc-lab-1] ① 파드를 전부 교체한다"
kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
로그인은 그대로 둔 채 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
**예상 결과** — 실측은 이렇다(observed, `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` 다.
```text
사용자 관점: 로그인되어 있다고 나온다
실제: BFF 가 사용자를 대신해 아무것도 못 한다
```
파드가 전부 교체됐는데 로그인 상태는 살아남았다. Redis 덕분이다. 토큰은 같이 살아남지 못했다. 인스턴스 메모리에 있었으니까. 원 가이드는 이것을 부분적으로만 공유했을 때의 실패 모양이라고 부르고, 완전히 로그아웃되는 편이 차라리 낫다고 적는다. 적어도 사용자가 다시 로그인하는데, 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.
| | B-0 (Redis 없음, replica 1) | B-1 (Redis 세션, replica 2) |
|---|---|---|
| `principal` | labuser | labuser |
| `accessTokenStoredOnServer` | true | false |
| 파드 재시작 후 | 로그아웃 | 로그인 상태만 남고 토큰은 소실 |
**문제가 생기면** — 스크린샷으로 시점을 구별하지 않는다. 증거 폴더의 `README.md` 가 적어 둔 대로 `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는 동일 파일이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은 화면이었고, 시점 구별은 터미널 출력과 Redis·DB 조회가 한다.
그래서 무엇을 해야 하는가 — `OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 따로 필요하다.
| 후보 | |
|---|---|
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. PostgreSQL 이 이미 있다 |
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
세 번째는 조회 키 문제까지 같이 푼다. 세션 단위로 저장되므로 같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않고, 대신 세션이 커진다. B-2 가 이 선택지를 비교한다.
## 복구와 원상복구 확인표
B-2 로 이어갈 것이면 이 구성이 B-2 의 출발이므로 아무것도 안 되돌린다.
### 1. 소스를 되돌리고 다시 빌드해 다시 밀어 넣는다
B-0 상태로 되돌릴 때는 소스만 되돌려서는 안 된다. 클러스터에는 여전히 옛 이미지가 돈다.
```bash label="[워크스테이션] ① 네 파일을 되돌리고 확인한다"
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 label="[워크스테이션 → kc-lab-1] ② 다시 빌드해 두 노드에 다시 넣고 다시 배포한다"
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 -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
② 도 한 블록에 기계가 둘이다. `docker` 로 시작하는 앞의 세 줄은 워크스테이션, `kubectl` 로 시작하는 뒤의 세 줄은 `kc-lab-1` 이다. ① 이 되돌린 파일이 `kc-lab-1` 쪽 체크아웃에도 반영돼 있어야 `apply` 가 B-0 구성을 올린다.
`git status --short` 가 빈 출력이어도 클러스터는 아직 옛 이미지를 쓰고 있다. ② 를 끝내야 소스와 클러스터가 같아진다.
### 2. Redis 를 비운다
Redis 를 비우는 것은 되돌릴 수 없다. 지운 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다. 실험대라서 하는 일이다.
```bash label="[kc-lab-1] ① 개수를 보고 비우고 다시 센다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
세션 하나만 지우려면 이쪽이다. `$KEY` 는 관찰 2번에서 담아 둔 키이고, 그 뒤로 `rollout restart` 와 브라우저 왕복이 들어가 절차가 40분쯤 걸리므로 터미널을 새로 열었으면 값이 비어 있다. 치기 전에 `echo "$KEY"` 로 키가 나오는지 보고, 안 나오면 관찰 2번의 `KEY=$(...)` 두 줄을 다시 쳐서 담는다. 바로 위 ① 의 `flushdb` 를 이미 쳤으면 지울 키가 없으므로 ② 를 건너뛴다.
```bash label="[kc-lab-1] ② 잡아 둔 키 하나만 지운다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
```
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) |
| 이미지 | `sudo k3s ctr images ls \| grep keycloak-pattern-bff` | 두 노드 모두에 있다 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 |
| Redis | `kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` |
| Redis 키 | `... redis-cli dbsize` | 의도한 값 |
| Keycloak | `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` | — |
표의 두 줄은 한 기계에서 다 못 본다. **이미지** 줄의 `sudo k3s ctr images ls` 는 그 명령을 친 노드 하나만 본다. `kc-lab-1` 에서 치면 `kc-lab-2` 는 안 보이므로, 「두 노드 모두에 있다」를 확인하려면 B-0 이 짝으로 친 `ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'` 도 함께 봐야 한다. 한쪽에만 있으면 그 노드의 replica 만 뜨고 나머지는 `ErrImageNeverPull` 로 나타난다. **임시 파일** 줄도 앞의 두 파일은 `kc-lab-1`, `/tmp/build.log` 는 워크스테이션에 있으므로 각 기계에서 자기 쪽을 지운다.
## 막히면
원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 파드가 `CrashLoopBackOff` 이고 오류에 `tcp://...:6379` | 쿠버네티스가 `REDIS_PORT` 를 주입했다 | `printenv \| grep -i redis` |
| 위 오류를 Redis 가 죽어서로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 |
| `enableServiceLinks` 를 넣었는데 그대로다 | 파드가 아직 옛 것이다 | `rollout restart` 후 `printenv` 다시 |
| 빌드가 Redis 연결 오류로 죽는다 | 테스트가 Redis 를 찾는다 | `spring.session.store-type=none` |
| `sessionRepository` 가 안 생긴다 | 의존성을 하나만 넣었다. 오류 없이 메모리에 남는다 | 두 개 다 있는지 `pom.xml` |
| `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 |
| `hkeys` 가 빈 결과 | 만료 추적 키를 잡았다 | `grep -v expires` |
| 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` |
| 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` |
| API 호출이 `500` 인데 토큰은 멀쩡하다 | DNS 다. 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` |
| `/actuator/beans` 가 `Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 |
| `jq: command not found` | 이 실험대에 `jq` 가 없다 | `grep` 으로 읽는다 |
| 로그인은 되는데 API 가 전부 실패한다 | 이게 이 실험의 결론이다 | `token-boundary` 의 두 `false` |
| 스크린샷으로 시점을 구별하려다 헷갈린다 | 두 파일이 동일하다 | 터미널 출력과 Redis 조회로 구별 |
`500` 쪽은 원인을 찾는 데 한 번 헛짚었다. 로그를 보니 토큰이 아니라 DNS 였다.
아래 줄의 `$BFF` 는 주입 검증 1번에서 담아 둔 파드 이름이다. 「막히면」은 아무 때나 펼치는 절이고 그 사이에 관찰 4번의 `rollout restart` 가 파드를 통째로 갈아치우므로, 그대로 치면 `NotFound` 가 온다. 치기 전에 주입 검증 1번의 `BFF=$(...)` 두 줄을 다시 쳐서 이름을 새로 담는다.
```bash label="[kc-lab-1] 예외만 골라 본다"
kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'
```
```text
java.nio.channels.UnresolvedAddressException
```
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo` 는 `header-lab` 네임스페이스의 8081 이었다. 배포조차 되어 있지 않았다.
```yaml
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
- name: RESOURCE_API_BASE_URL
value: http://echo.header-lab.svc:8081
```
```bash label="[kc-lab-1] 그 서비스가 어디 있는지 본다"
kubectl -n header-lab get svc echo
```
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 13:5914:03 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 자동 주입된 `REDIS_*` 일곱 줄과 `REDIS_PORT=tcp://10.43.57.116:6379`, `Failed to bind properties` 오류 전문, `enableServiceLinks: false` 뒤의 롤아웃 출력 세 줄, 빈 수 `321 → 402 (+81)`, 새로 생긴 세션 저장소 빈 여섯 줄, 안 바뀐 인가된 클라이언트 빈 세 개의 before 와 after, Redis 키 `bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae` 와 필드 일곱 개, `TTL: 1772 초`, `\xac\xed` 로 시작하는 바이트, 재시작 뒤 `token-boundary` 의 `principal` 생존과 두 토큰 `false`, `UnresolvedAddressException`.
- (unknown) 빈을 세는 `grep -o '"aliases":\['` 줄과 이름·타입을 뽑는 `grep`·`sed` 줄. 원 가이드가 미검증으로 표시했다. `jq` 판본과, `--scan` 출력에서 키를 눈으로 골라 치는 두 단계 형태와, 두 겹 `ssh` 를 나눠 치는 형태도 가이드에 없다.
- (observed) `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 가 동일 파일이라는 것은 증거 폴더의 `README.md` 가 적어 둔 사실이다. 같은 화면이 세 시점에 나왔기 때문이고, 그래서 시점은 터미널 출력과 저장소 조회로만 갈린다.
- 이 실험이 재지 않은 것 셋 — Redis 를 끊었을 때 무엇이 나는지는 B-5 의 주제이고, 로그아웃 뒤 두 저장소에 무엇이 있는지는 B-2 로 넘겼으며, 저장소 지연이 화면 지연으로 얼마나 번역되는지는 B-2 이후에 잰다.
<!-- body:end -->
@@ -0,0 +1,780 @@
---
id: df2ee798-7f31-4570-a355-11f96c0eea84
kind: SETUP
slug: reproduce-b2-jdbc-token-store
title: 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/df2ee798-7f31-4570-a355-11f96c0eea84/edit"
pinnedVersions:
- name: keycloak-pattern-bff
version: lab
- name: Redis
version: 7.4.x
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-2
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다
B-1 이 Redis 로 안 옮긴 토큰을 PostgreSQL 로 옮겨 보는 절차다. `JdbcOAuth2AuthorizedClientService` 를 걸고 기본키 한 줄과 로그아웃 뒤 세 저장소의 숫자를 읽는다. 전 구간 약 25분이고 브라우저와 터미널을 나란히 둔다.
## 관계
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
이 절차가 낸 결론을 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
스키마 초기화가 조용히 실패하고 파드는 정상으로 보이는 것이 그 아홉 건 가운데 하나다.
- **저장소를 옮기기 전에 조회 키를 본다**
`\d oauth2_authorized_client` 를 주입 전에 치는 순서를 규칙으로 굳힌 기록이다.
- **세션과 토큰의 저장소를 나눠 각각 설계한다**
여기서 나온 덮어쓰기와 한쪽만 정리되는 로그아웃이 그 결정의 근거가 된다.
- **Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다**
먼저 해 둬야 하는 편이다. 그 편이 만든 구성 위에서 이 절차가 시작한다.
- **같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다**
다음 편이다. 여기서 만든 `oauth2_authorized_client` 표를 그 편이 그대로 쓰므로 표를 지우지 않는다.
## 본문
<!-- body:start -->
## 먼저 읽는다 — 이 편만 따라 쳐서는 주입이 재현되지 않는다
**이 절차에는 JDBC 토큰 저장소 배선을 넣는 단계가 없다.** 배선을 넣는 편집 명령도 그때 친 빌드 명령도 B-2 의 원 가이드에 없고(unknown), 없는 명령을 지어내지 않았다. 아래 절차는 그 배선이 이미 들어간 BFF 가 떠 있는 상태에서 시작한다 — 주입 전 1번이 인용하는 `deployment "bff" successfully rolled out` 두 줄이 그 배포가 남긴 출력이다.
**B-0 과 B-1 을 글자 그대로 따라 친 사람은 그 상태가 아니다.** B-0 의 주입 절이 `spring-boot-starter-jdbc` · `postgresql` · `h2``SecurityConfig` 의 명시 빈 둘과 `spring.datasource``BFF_DB_*`**지우고**, B-1 은 그것을 되살리지 않는다. 그래서 B-0 → B-1 → B-2 순으로 온 BFF 에는 `JdbcOAuth2AuthorizedClientService` 가 없다.
**그 상태로 쳐도 화면은 정상으로 보인다.** 주입 전 3번이 표를 만들고 `\d oauth2_authorized_client` 도 정의를 돌려주는데, 행만 한 번도 안 생긴다. 그러면 주입 전 6번의 「이 값이 아직 `false` 로 나오면 … 로그아웃하고 다시 로그인한다」가 끝나지 않는 고리가 되어 로그인만 반복하게 된다. 두 번 재로그인해도 `accessTokenStoredOnServer``false` 이고 표의 행이 0이면 배선이 안 들어간 상태이므로 이 절차를 멈춘다.
무엇을 넣어야 하는지는 B-0 의 주입 절이 지울 목록으로 적어 두었고, 바로 아래 「전제와 되돌리기」가 그 넷을 표로 옮겨 두었다. 그 넷을 되돌려 넣는 순서와 그때 친 빌드 명령은 원 가이드에 없다(unknown).
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. `kubectl``sudo` 를 붙이지 않는다. 브라우저에서 버튼을 누르고 터미널에서 저장소를 세는 왕복이 이 절차의 대부분이라 터미널 하나와 브라우저 창 하나를 나란히 둔다. 로그아웃 한 단계만 브라우저 개발자 도구의 콘솔에서 친다.
예외는 아래 「전제와 되돌리기」의 두 블록뿐이다. 거기서만 워크스테이션의 저장소를 고치고 이미지를 다시 만든다. **그 기계로 건너가는 명령은 이 절차에 없다** — 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다(`ssh test-server "ssh kc-lab-1 '...'"`, 워크스테이션에서 `test-server` 를 거쳐 `kc-lab-1` 로 간다). 그 블록의 `bff/pom.xml``deploy/lab/k8s/bff-redis.yaml` 도 저장소 상대경로라 어느 디렉터리에서 치는지 적힌 줄이 없으므로, 두 기계 각각에서 저장소 루트로 옮겨 두고 시작한다. 이 셋의 명령이 원 가이드에 없다(unknown).
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 시작 상태 | B-0 과 B-1 이 끝나 BFF 가 replica 2 이고 Redis 가 세션 저장소다 |
| 주입 수단 | Redis 세션을 지우고 같은 사용자로 다시 로그인시킨다 |
| 읽어야 하는 한 줄 | `\d oauth2_authorized_client` 의 맨 아래 `Indexes:` |
| 브라우저 | 필요하다. 인가 코드 흐름을 `curl` 로 만들 수 없다 |
| 세는 저장소 | 셋 — Redis 의 `bff:session:*`, PostgreSQL 의 `oauth2_authorized_client`, Keycloak 세션 |
| 걸리는 시간 | 약 25분 |
## 이 실험이 가르는 것
B-1 이 Application Session 만 Redis 로 옮겼고, 그러자 사용자는 로그인 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다. 세션과 토큰이 서로 다른 것에 들어 있고 한쪽만 옮겼기 때문이다. 토큰도 공유 저장소로 옮기면 그건 고쳐진다. 이 실험이 묻는 것은 무엇이 같이 고쳐지고 무엇이 안 고쳐지는가다.
| | 예측 |
|---|---|
| 통념 | 공유 저장소로 옮기면 다중 인스턴스 문제가 해결된다 |
| B-2 모델 | 인스턴스 간 공유만 해결되고 브라우저 간 격리와 로그아웃 정리는 안 바뀐다 |
어디에 두는가와 어떻게 찾는가는 서로 독립이다.
```text
저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다
조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다
```
이 실험이 판정하는 것은 두 번째이고, 키는 코드가 아니라 스키마에 박혀 있다. 그래서 구현을 바꾸면 되겠지로 넘어갈 수 없고, 주입하기 전에 그 줄을 직접 읽는다.
같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데 셋이 서로 다른 시스템에 있다.
```text
① HttpSession Redis Spring Security 가 지운다
② OAuth2AuthorizedClient PostgreSQL ★ 아무도 안 지운다
③ IdP SSO 세션 Keycloak ★ RP-initiated logout 을 보내야 한다
```
## 전제와 되돌리기
- `05-keycloak``06-observability` 가 끝나 있다.
- B-0 과 B-1 이 끝나 BFF 가 replica 2개로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
- 명령은 `kc-lab-1` 에서 친다.
- 브라우저가 필요하다. `https://app1.hyeonworks.com/` 에 붙어 realm `keycloak-patterns``labuser` 로 들어간다.
- 터미널 하나와 브라우저 창 하나를 나란히 둔다.
상태를 바꾸는 실험이다. DDL 을 태우고 Redis 세션을 지우고 로그아웃한다. 실험대에서만 한다. 중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.
되돌리기가 어디까지 가는지는 무엇을 되돌리느냐로 갈린다.
| 무엇을 되돌리나 | 어디까지 가나 |
|---|---|
| 지운 Redis 세션 | 브라우저에서 다시 로그인한다. 지운 세션 자체는 되살아나지 않는다 |
| 덮어쓴 `oauth2_authorized_client` 행 | 다시 로그인하면 새 행이 만들어진다. 덮이기 전 토큰은 돌아오지 않는다 |
| 끊은 Keycloak SSO 세션 | 브라우저에서 다시 로그인한다 |
| `oauth2_authorized_client` 표 | `drop table` 이 있지만 B-3 이 이 표를 쓰므로 평소에는 치지 않는다 |
| JDBC 토큰 저장소 배선 자체 | 소스를 되돌리고 다시 빌드해 두 노드에 다시 import 해야 한다 |
마지막 줄이 B층과 A층이 갈리는 곳이다. 이 편의 절차 안에는 소스를 고치는 단계가 없고 B-1 이 만든 구성 위에서 시작하는데, JDBC 배선을 걷어내려면 애플리케이션을 다시 빌드해야 한다. B-2 가 소스에 넣은 것이 무엇인지는 B-0 의 주입 절이 지울 목록으로 적어 두었다.
| B-2 가 넣은 것 | 어느 파일 |
|---|---|
| `spring-boot-starter-jdbc` · `postgresql` · `h2` | `bff/pom.xml` |
| `OAuth2AuthorizedClientService``OAuth2AuthorizedClientManager` 명시 빈 | `SecurityConfig.java` |
| `spring.datasource` · `spring.sql.init` | `application.yml` |
| `BFF_DB_URL` · `BFF_DB_USER` · `BFF_DB_PASSWORD` | `deploy/lab/k8s/bff-redis.yaml` |
그 넷을 되돌리는 명령은 B-0 의 되돌리기와 같은 한 줄이고, 소스만 되돌리면 클러스터에는 여전히 옛 이미지가 도므로 다시 빌드해 두 노드에 다시 밀어 넣는 데까지 가야 한다.
```bash label="[워크스테이션] 네 파일을 되돌린다"
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
```
```bash label="[워크스테이션 → kc-lab-1] 다시 빌드해 두 노드에 다시 넣고 다시 배포한다"
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 -'"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout restart deployment/bff
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
위 블록은 한 블록인데 기계가 둘이다. `docker` 로 시작하는 앞의 세 줄은 워크스테이션에서 치고, `kubectl` 로 시작하는 뒤의 세 줄은 `kc-lab-1` 에서 친다. 워크스테이션에는 kubeconfig 가 없어 거기서 `kubectl` 을 치면 클러스터에 못 붙고 끝난다. `kubectl apply` 가 읽는 `deploy/lab/k8s/bff-redis.yaml` 은 바로 위에서 `git checkout` 으로 되돌린 그 파일이므로 `kc-lab-1` 쪽 체크아웃에도 같은 내용이 있어야 하는데, 옮기는 명령은 원 가이드에 없다(unknown).
B-2 자신의 가이드에는 JDBC 배선을 넣는 편집 명령도 그때 친 빌드 명령도 없다(unknown). 증거에 남은 것은 배포 결과 두 줄뿐이고, 위 되돌리기는 B-0 이 적어 둔 목록과 B-1 이 적어 둔 재빌드 순서를 그대로 옮겼다.
## 주입 전에 같은 명령으로 먼저 본다
시험군만 재는 측정은 측정이 아니다. 덮어쓰기를 보려면 덮어쓰이기 전의 행이 있어야 하고, 로그아웃 정리를 보려면 로그아웃 전의 세 숫자가 있어야 한다.
```text
파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
```
### 1. BFF 두 개가 다른 노드에 있는가
**무엇을 보는가** — 파드 배치와 재시작 횟수.
```bash label="[kc-lab-1] 네임스페이스 전체를 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — 모양은 이렇고 주소와 해시는 환경마다 다르다(observed).
```text
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 의 증거에 남은 실제 BFF 파드 주소다. Redis 와 PostgreSQL 은 매니페스트가 `nodeSelector` 로 `kc-lab-2` 에 고정해 둔다.
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
```bash label="[kc-lab-1] 라벨로 BFF 만 본다"
kubectl -n keycloak-lab get pods -l app=bff
```
실측은 이렇다(observed, `01-jdbc-store-deploy.txt`). 위 두 줄은 JDBC 토큰 저장소를 올린 배포 명령이 같이 찍은 것이고, 이 절차는 그 배포가 끝난 뒤부터 시작한다.
```text
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
```
**이 값이 뜻하는 것** — `bff` 가 두 개이고 `READY` 가 둘 다 `1/1` 이어야 하며 `NODE` 가 서로 달라야 한다. 같은 노드에 몰려 있으면 다른 인스턴스가 같은 커널 위의 다른 프로세스일 뿐이고, `topologySpreadConstraints` 가 이걸 벌려 놓는다. replica 가 하나면 이 실험의 질문이 성립하지 않는다. `RESTARTS` 가 `0` 인 것도 적어 둔다. 뒤에서 이 값이 오르면 건드린 것이 엉뚱한 데 닿았다.
### 2. 토큰이 들어갈 테이블이 실제로 있는가
**무엇을 보는가** — `oauth2_authorized_client` 가 있는지. 원래 실행은 여기서 한 번 넘어졌다. 파드는 떴고 Hikari 도 붙었는데 테이블이 없었고 아무도 그것을 신고하지 않았다.
```bash label="[kc-lab-1] 테이블 정의를 물어본다"
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
```
**어디를 보나** — 실측은 이렇다(observed, `01-jdbc-store-deploy.txt`).
```text
=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1
```
**이 값이 뜻하는 것** — 이 두 줄이 나오면 아직 아무것도 저장되지 않는 상태다. 스키마 초기화가 조용히 실패했고 원인은 타입 이름 하나다. 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 의 주제다.
### 3. PostgreSQL 전용 DDL 을 파일로 만들어 태운다
**목적** — 토큰이 들어갈 표를 만든다.
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 파일로 만든다. 터미널에 붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
```bash label="[kc-lab-1] ① 편집기로 DDL 파일을 만든다"
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` 의 `=== PostgreSQL 전용 스키마 ===` 절 원문이다(observed).
```bash label="[kc-lab-1] ② 파일을 파드 안으로 넘겨 태운다"
kubectl -n keycloak-lab exec -i deploy/postgres -- \
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
```
**예상 결과** — 실측은 이렇다(observed, `02-schema.txt`).
```text
=== 적용 ===
CREATE TABLE
```
**왜 필요한가** — `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안 일어나고 오류도 안 난다. `kubectl exec` 는 stdin 을 기본으로 연결하지 않는다.
**문제가 생기면** — 되돌리는 명령은 있지만 평소에는 치지 않는다. B-3 이후로도 이 표를 계속 쓴다.
```bash label="[kc-lab-1] 표를 지운다. 평소에는 치지 않는다"
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'
```
### 4. 기본키를 눈으로 읽는다
**무엇을 보는가** — 이 편의 답이 박혀 있는 한 줄. 이 줄을 보기 전에는 다음으로 넘어가지 않는다.
```bash label="[kc-lab-1] 테이블 정의를 다시 물어본다"
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
```
**어디를 보나** — 실측은 이렇다(observed, `02-schema.txt`).
```text
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:` 줄 하나가 답이다.
```text
PRIMARY KEY, btree (client_registration_id, principal_name)
└── "keycloak" ──┘ └── "labuser" ──┘
세션 id 가 없다
```
**이 값이 뜻하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든 `(keycloak, labuser)` 라는 한 행을 쓴다. B-0 에서 빈 이름인 `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 로 짐작했던 것이 테이블 정의로 확정된다. 저장소를 Redis 로 바꿔도 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.
### 5. 세 저장소를 세는 명령을 확정한다
**무엇을 보는가** — 관찰 절에서 로그아웃 전후로 견줄 숫자 셋. 다른 명령으로 재면 비교가 아니다.
```bash label="[kc-lab-1] ① BFF 세션 키를 접두어로 골라 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
```
모양은 B-1 측정과 같다(observed).
```text
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
```
`KEYS *` 대신 `--scan` 을 쓰는 것은 `KEYS` 가 Redis 를 잡아 두고 전 키를 훑기 때문이다. 그리고 `dbsize` 는 이 실험에서 부정확하다. Redis 하나를 BFF 와 B-7 의 oauth2-proxy 가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다. 접두어로 걸러 세는 쪽이 맞다.
```bash label="[kc-lab-1] ② 토큰 행 수를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
```
```bash label="[kc-lab-1] ③ Keycloak 세션을 DB 쪽에서 센다"
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_user_session` 에 `offline_flag = 0` 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 원 가이드가 미검증으로 표시했다(unknown). 관리 API 로 보려면 이쪽이다.
```bash label="[kc-lab-1] ④ 관리 API 로 세는 형태"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get client-session-stats -r keycloak-patterns
```
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않고, 존재와 길이만 보려면 `base64 -d | wc -c` 로 센다.
### 6. 브라우저로 로그인하고 토큰 경계를 읽는다
**무엇을 보는가** — 토큰이 이제 서버에 있는지.
브라우저에서 `https://app1.hyeonworks.com/` 를 열고 Keycloak 로그인을 눌러 `labuser` 로 들어간 뒤 token 경계 확인을 누른다. realm 은 `keycloak-patterns` 이고 비밀번호는 B-0 에서 그 사용자를 만들 때 정한 값이다.
**어디를 보나** — 실측은 이렇다(observed, 해설 문서 3절).
```json
{"principal":"labuser",
"accessTokenStoredOnServer":true, ← B-1 에서는 false 였다
"refreshTokenStoredOnServer":true,
"browserTokenCount":0}
```
**이 값이 뜻하는 것** — `accessTokenStoredOnServer` 가 `true` 다. B-1 에서는 인가된 클라이언트가 프로세스 메모리에 있어 로그인을 처리하지 않은 replica 가 답하면 아무것도 못 찾았고, 지금은 두 replica 가 같은 PostgreSQL 행을 본다. 이 값이 아직 `false` 로 나오면 표는 만들었는데 옛 세션을 쓰고 있는 것이므로 로그아웃하고 다시 로그인한다. 증거의 `b2-before-relogin.png` 가 정확히 그 상태다.
### 7. 대조군 행을 잡는다
**무엇을 보는가** — 덮어쓰이기 전의 행 수와 토큰 해시와 발급 시각.
```bash label="[kc-lab-1] ① 행 수와 토큰 해시와 발급 시각을 함께 본다"
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"
```
**어디를 보나** — 실측은 이렇다(observed, `04-overwrite-test.txt`).
```text
=== [현재] 같은 사용자의 항목 ===
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` 둘 다 적어 둔다. 토큰 값이 아니라 md5 를 보는 까닭은, 값 자체가 지금 쓸 수 있는 자격증명이라 터미널 스크롤백에 남기면 안 되기 때문이다. md5 는 같은가 다른가만 답하고 그것이 이 단계가 묻는 전부다.
크기도 같이 본다.
```bash label="[kc-lab-1] ② 두 토큰의 바이트 수를 본다"
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"
```
실측은 이렇다(observed, 해설 문서 3절. 이 표는 `.txt` 증거에는 없고 문서에만 있다).
```text
client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
keycloak | labuser | Bearer | 1431 | 744
```
## 주입
### 1. 세션을 지우고 같은 사용자로 다시 로그인시킨다
**목적** — 두 번째 브라우저에 해당하는 상태를 만든다. 조회 키가 `(clientRegistrationId, principalName)` 이므로 브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가다.
증거 `04-overwrite-test.txt` 는 실제로 한 일을 이렇게 적었다(observed).
```text
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
```
해설 문서는 처음에 두 브라우저에서라고 적었다가 측정하지 않은 것을 측정한 것처럼 적었다고 정정했다. 진짜로 두 브라우저를 쓰려면 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 되고, 결과는 같아야 하며 다르면 그게 더 중요한 발견이다.
지우기 전에 무엇을 지울지 눈으로 본다. 이 Redis 는 BFF 혼자 쓰는 것이 아니다.
```bash label="[kc-lab-1] ① 전체 키를 한 번 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
모양은 이렇다(observed).
```text
bff:session:sessions:c63c39ee-...
bff:session:expires:c63c39ee-...
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
```
`_oauth2_proxy-` 로 시작하는 키가 섞여 있으면 `FLUSHALL` 을 치면 안 된다. B-7 의 oauth2-proxy 세션까지 날아가 그쪽 실험이 오염된다. 접두어로 골라 지운다.
원래 실행은 스크립트를 돌렸고 아래 형태는 원 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시한 것이다(unknown). 후속 문서 3절이 oauth2-proxy 세션을 지울 때 쓴 것과 같은 모양이다.
```bash label="[kc-lab-1] ② BFF 세션만 골라 지우고 시각을 남긴다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
| xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
date '+%H:%M:%S 세션 삭제'
```
이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. `redis-cli` 출력은 CR 을 달고 오므로 `xargs` 가 넘기는 키 이름이 실제 키와 안 맞을 수 있고, 그때 `del` 은 오류 없이 `(integer) 0` 을 돌려준 뒤 `date` 줄은 그대로 「세션 삭제」를 찍는다. 원 가이드의 이 줄에 그 조각이 없어 여기에도 안 넣었다(unknown).
**예상 결과** — 모양은 이렇다(observed).
```text
(integer) 2
16:21:03 세션 삭제
```
`(integer) 0` 이 나왔으면 지워진 키가 없다. 그대로 다음으로 가지 말고 주입 검증 1번을 먼저 친다 — 첫 명령에 `bff:session:*` 키가 남아 있으면 세션이 안 지워진 상태이고, 그 위에서 관찰 절을 재면 덮어쓰기가 아니라 아무 일도 안 일어난 것을 재게 된다.
**왜 필요한가** — 시각을 적어 둔다. 뒤에서 `access_token_issued_at` 이 이 시각 뒤인지로 새 로그인이 실제로 일어났는가를 판정한다.
**문제가 생기면** — `_oauth2_proxy-*` 키까지 사라졌으면 `FLUSHALL` 을 쳐서 B-7 세션까지 지웠다. 그 상태는 이 절차로 되돌릴 수 없고 B-7 쪽에서 다시 로그인해야 한다.
## 주입 검증
결과를 읽기 전에 주입이 의도한 것만 건드렸는지 먼저 본다.
### 1. BFF 세션만 사라지고 B-7 키는 그대로인가
```bash label="[kc-lab-1] 두 접두어를 따로 센다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
```
첫 명령은 아무것도 안 나와야 하고 두 번째는 주입 전과 같아야 한다. 두 번째까지 비었으면 `FLUSHALL` 을 쳤다.
### 2. BFF 가 재시작되지 않았는가
```bash label="[kc-lab-1] 재시작 횟수를 본다"
kubectl -n keycloak-lab get pods -l app=bff
```
`RESTARTS` 가 여전히 `0` 이어야 한다. 세션을 지우는 것은 BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고, 그 상태로 재면 덮어쓰기가 아니라 파드 재시작을 재게 된다.
### 3. 조용한 재인증이 실제로 일어났는가
브라우저에서 `https://app1.hyeonworks.com/` 를 새로고침하고 token 경계 확인을 누른다. 로그인 화면이 뜨지 않고 그냥 들어가진다. Redis 세션은 지워졌지만 Keycloak SSO 세션은 살아 있어서, BFF 가 `/oauth2/authorization/keycloak` 으로 보내면 Keycloak 이 화면 없이 즉시 코드를 돌려주고 새 로그인 한 벌이 조용히 만들어진다. 이것이 모의 두 번째 브라우저다. 같은 조용한 재인증이 로그아웃 뒤에는 로그아웃했는데 다시 들어가진다로 보인다. 같은 성질의 양면이다.
## 관찰
### 1. 행이 늘었는가 덮어써졌는가
**무엇을 보는가** — 주입 전에 친 것과 똑같은 명령의 결과.
```bash label="[kc-lab-1] 대조군과 같은 질의를 다시 친다"
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"
```
**어디를 보나** — 실측은 이렇다(observed, `04-overwrite-test.txt`).
```text
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
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 row)` | `(1 row)` | INSERT 가 아니다 |
| `at_md5` | `675af228…` | `e19a63fc…` | 내용은 바뀌었다 |
| `issued_at` | `05:10:46` | `05:12:13` | 삭제 시각 뒤 = 새 로그인 맞다 |
**이 값이 뜻하는 것** — 셋 중 하나만 보면 틀린다. 행 수만 보면 아무 일도 없었다로, md5 만 보면 새 행이 생겼나로 읽힌다. UPDATE 다.
```text
브라우저 A 로그인 → (keycloak, labuser) 행 생성
브라우저 B 로그인 → 같은 행을 덮어쓴다
└─ A 의 토큰은 사라진다
```
A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다. 같은 사용자이므로 당장은 아무 증상이 없고 증상은 나중에 나온다.
| 언제 문제가 되는가 | |
|---|---|
| B 가 로그아웃하면 | A 도 같이 끊긴다. 행이 지워지므로 |
| refresh 회전이 켜져 있으면 | A 와 B 가 같은 refresh token 을 다툰다 → B-3 |
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
저장소를 바꾸면 고쳐지는가 — 안 고쳐진다. `PRIMARY KEY` 줄이 답이다.
```text
InMemory → PostgreSQL → Redis → 직접 구현
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
```
고치려면 조회 키에 세션을 넣어야 하고, 그것은 저장소가 아니라 `OAuth2AuthorizedClientRepository` 쪽 이야기다.
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|---|---|---|
| `JdbcOAuth2AuthorizedClientService` | 불필요 (같은 인터페이스) | 안 고쳐짐 |
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
| `HttpSessionOAuth2AuthorizedClientRepository` | 필요 (Repository 로 바꿔야) | 고쳐짐 |
이 실험이 세 번째를 고르지 않은 것은 Q3 가 Redis 와 JDBC 중 무엇을 물었기 때문이고, 그 대가로 조회 키 문제가 풀리지 않았다. 선택이 남긴 자국을 측정한 것이지 실수가 아니다.
### 2. 저장된 토큰이 평문인가
**무엇을 보는가** — `bytea` 안에 든 것이 암호화된 덩어리인지 JWT 문자열인지.
값을 찍기 전에 무엇을 찍게 될지 길이로 먼저 안다.
```bash label="[kc-lab-1] ① refresh token 의 바이트 수만 본다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select length(refresh_token_value) from oauth2_authorized_client"
```
실측은 `744` 다(observed, 해설 문서 3절의 `rt_len`). 암호화된 덩어리라면 여기서 알 수 없으므로 앞 몇 글자만 본다.
원래 실행은 앞 200자 남짓을 통째로 찍었다. 아래 형태는 화면에 남는 양을 줄인 것이고 원 가이드가 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] ② 앞 40자만 텍스트로 디코드해 본다"
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"
```
**어디를 보나** — 실측은 `03-plaintext-tokens.txt` 에 있고, 원래 실행이 찍은 문자열 가운데 앞 36자만 옮긴다(observed). 그 뒤는 지금 쓸 수 있는 자격증명이라 증거 파일에만 둔다.
```text
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
```
**이 값이 뜻하는 것** — `eyJ` 로 시작한다. 그것이 `{"` 의 base64 이고 JWT 는 예외 없이 이렇게 시작한다. `convert_from` 이 성공하는 것 자체가 답을 준다. 암호화된 바이트라면 UTF-8 로 디코드되지 않고 오류가 나므로, 읽힌다는 것은 텍스트라는 뜻이다.
정말 JWT 인지 헤더를 풀어 본다. 원 가이드는 이 줄도 미검증으로 표시한다(unknown).
```bash label="[kc-lab-1] ③ 첫 조각만 잘라 base64 로 푼다"
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
```
실측은 이렇다(observed, `03-plaintext-tokens.txt`).
```text
=== 저장된 바이트를 그대로 디코드한 결과 ===
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` 를 감싸거나 직접 구현해야 한다.
원래 실행은 여기서 한 번 넘어졌고 증거 파일에 그 실패가 그대로 있다(observed, `03-plaintext-tokens.txt`).
```text
=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
File "<string>", line 3
h=open(/tmp/hdr.txt).read().strip()
^
SyntaxError: invalid syntax
```
파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨렸다. 셸 안에 프로그램을 밀어 넣으면 문법 오류가 측정 결과 칸에 남는다. `cut` 과 `base64 -d` 로 충분하고 그 둘은 문법이 틀릴 곳이 없다.
### 3. 로그아웃하면 세 저장소가 다 정리되는가
**목적** — 로그아웃 전후의 세 숫자를 같은 명령으로 견준다.
로그아웃 전에 세 숫자를 먼저 잡는다. 주입 전에 정해 둔 명령 그대로다.
```bash label="[kc-lab-1] ① 로그아웃 전 두 숫자를 잡는다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'
```
실측은 이렇다(observed, `04-overwrite-test.txt`).
```text
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
로그아웃 전
Redis: 1 키
PostgreSQL: 1 행
```
화면에 로그아웃 버튼이 없다. `index.html` 에는 로그인과 조회 버튼만 있다. Spring Security 의 로그아웃은 CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다. 로그인된 app1 탭에서 `F12` 를 눌러 Console 로 간다. 원 가이드가 미검증으로 표시한 조각이다(unknown).
```js label="[브라우저 콘솔] 로그아웃을 POST 로 보낸다"
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 label="[kc-lab-1] ② 로그아웃 뒤 세 숫자를 같은 명령으로 잡는다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
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"
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**예상 결과** — 실측은 이렇다(observed, `05-logout-cleanup.txt`).
```text
=== 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
```
세 숫자를 나란히 놓으면 하나만 지워졌다.
```text
로그아웃 후:
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
Keycloak SSO : 2 세션 ← 남아 있다
```
```text
로그아웃
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
```
**왜 필요한가** — 남은 행의 `access_token_expires_at` 이 `issued_at` 의 60초 뒤인 것도 같이 본다. B-0 에서 `accessTokenLifespan=60` 으로 잡았기 때문이다. access token 은 이미 만료됐는데 같은 행의 refresh token 은 아직 쓸 수 있고 그것은 평문이다.
브라우저에서 `https://app1.hyeonworks.com/` 를 다시 열면 로그인 화면이 안 뜨고 그냥 들어가진다. 주입 검증에서 본 것과 같은 조용한 재인증이다. 애플리케이션 세션은 지웠는데 IdP 세션은 살아 있으므로 IdP 가 화면 없이 새 세션을 만들어 주고, 사용자 입장에서는 로그아웃이 안 됐다.
| 필요한 것 | 방법 |
|---|---|
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
| Keycloak 세션 종료 | RP-initiated logout — `OidcClientInitiatedLogoutSuccessHandler` |
| 두 곳을 원자적으로 | 한쪽이 실패하면 어떻게 할지 — 정리 순서와 실패 처리를 정해야 한다 |
Q3 는 미지수 5번으로 「두 store 를 logout 에서 어떻게 한 번에 지우게 되는가」를 남겼는데, 이 실험이 그 답을 냈다 — 지금은 하나도 안 지운다.
**문제가 생기면** — 로그아웃 POST 가 `403` 이면 CSRF 토큰이 없거나 헤더 이름이 틀린 것이므로 `/bff/csrf` 의 `headerName` 을 그대로 쓴다. 되돌리기는 브라우저에서 다시 로그인하는 것이다.
## 복구와 원상복구 확인표
### 1. 남은 행을 지운다
```bash label="[kc-lab-1] 이 사용자의 행만 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"delete from oauth2_authorized_client where principal_name = 'labuser'"
```
모양은 `DELETE 1` 이다(observed). 브라우저에서 다시 로그인하면 행이 다시 만들어진다. 표 자체는 지우지 않는다. B-3 이 이 표를 쓴다.
### 2. Keycloak SSO 세션을 사람이 끊는다
RP 가 로그아웃을 안 보내 주므로 사람이 직접 끊는다. 브라우저에서 아래 주소를 열고 확인 화면이 뜨면 승인한다. 이 실험은 여기까지 재지 않았다(unknown).
```text
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
```
```bash label="[kc-lab-1] 세션 수가 줄었는지 본다"
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 이 안 되어도 놀랄 일이 아니다. 지운 Redis 세션은 되돌아오지 않으며 브라우저에서 다시 로그인하는 것이 복구다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 표 | `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-*'` | 주입 전과 같아야 한다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
| 임시 파일 | `rm -f /tmp/oauth2-pg.sql` | — |
JDBC 배선까지 걷어낼 것이면 전제와 되돌리기 절의 두 블록을 친다. 소스를 되돌리고 다시 빌드해 두 노드에 다시 import 한 뒤 배포해야 클러스터가 소스와 같아진다.
## 막히면
원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `Did not find any relation named "oauth2_authorized_client"` | 스키마 초기화가 조용히 실패했다. 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입 | `-postgres.sql` 판본을 태운다 |
| 파드는 정상인데 토큰이 저장되지 않는다 | 같은 원인. `continue-on-error: true` 가 실패를 삼켰다 | 파드 로그에서 `Did not find any relation` 을 찾는다 |
| `token-boundary` 가 계속 `false` | 표는 만들었는데 옛 세션을 쓰고 있다 | 로그아웃 후 재로그인 — `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 를 나눠 쓴다 | 접두어로만 지운다 |
| 파이썬 한 줄로 디코드하다 `SyntaxError` | 원래 실행이 이 실수를 했다 | `cut -d. -f1 \| base64 -d` 로 충분하다 |
| 로그아웃 POST 가 `403` | CSRF 토큰이 없거나 이름이 틀렸다 | `/bff/csrf` 의 `headerName` 을 그대로 쓴다 |
| 로그아웃했는데 다시 들어가진다 | 버그가 아니다. Keycloak SSO 세션이 살아 있다 | RP-initiated logout 을 사람이 연다 |
| `dbsize` 와 세어 본 키 수가 다르다 | oauth2-proxy 키가 섞여 있다 | `--scan --pattern` 으로 나눠 센다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:0914:13 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 테이블이 없을 때의 `Did not find any relation ...` 와 `exit code 1`, `CREATE TABLE`, 컬럼 표 전문과 `PRIMARY KEY, btree (client_registration_id, principal_name)`, 대조군 행의 `2026-09-04 05:10:46.927192` 와 `675af2286bfc2fd9d2bab7bc8f391df7`, 재로그인 뒤의 `2026-09-04 05:12:13.018828` 와 `e19a63fc5aa18bd0a68b3e19dff16b3b` 와 `(1 row)`, `at_len 1431` 과 `rt_len 744`, 디코드한 두 JWT 헤더와 페이로드 앞부분, 로그아웃 뒤 `Redis 0 키 · PostgreSQL 1 행 · Keycloak 온라인 세션 2`, `access_token_expires_at` 이 `issued_at` 의 60초 뒤인 것.
- (unknown) Keycloak 세션을 DB 쪽에서 세는 질의, `--scan | xargs ... redis-cli del` 로 BFF 세션만 지우는 줄, `left(convert_from(...), 40)` 으로 앞 40자만 찍는 줄, `cut` 과 `tr` 과 `base64 -d` 로 헤더를 푸는 줄, 브라우저 콘솔의 로그아웃 조각, RP-initiated logout 주소. 원 가이드가 전부 미검증으로 표시했다.
- 원래 실행과 다르게 적은 곳 — refresh token 값은 증거 파일에 200자 남짓이 있지만 여기에는 앞 36자만 옮겼다. 나머지는 지금 쓸 수 있는 자격증명이라 옮기지 않는다. `at_md5` 두 개는 해시라 그대로 적었다.
- (observed) 파이썬 한 줄로 JWT 헤더를 디코드하려다 난 `SyntaxError` 도 증거 파일에 있다. 그 시도가 깨진 뒤 `cut` 과 `base64 -d` 로 다시 받았다.
- 스크린샷으로는 판정하지 못한다 — `b2-tokens-shared-across-instances.png` 는 B-0 의 `b0-bff-token-boundary.png` 와 동일 파일이다(md5 `9ed00537…`). 두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다. 증명은 표가 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다.
- 이 실험이 재지 않은 것 — 진짜 두 브라우저를 열어 같은 결과가 나오는지는 재지 않았다. 세션을 지우고 다시 로그인하는 것이 등가인 까닭은 조회 키가 같기 때문이라는 추론이고 측정이 아니다. RP-initiated logout 을 열었을 때 세션 수가 실제로 줄어드는지도 재지 않았다.
- 이 편의 절차에는 소스를 고치는 단계가 없다(unknown). JDBC 토큰 저장소를 넣은 편집과 빌드는 증거에 배포 결과 두 줄로만 남았고, 되돌리기 절의 파일 목록은 B-0 이 적어 둔 지울 목록에서 가져왔다.
<!-- body:end -->
@@ -0,0 +1,719 @@
---
id: 405c4206-9b59-491f-aed1-8b97cfd9f584
kind: SETUP
slug: reproduce-b3-refresh-contention
title: 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/405c4206-9b59-491f-aed1-8b97cfd9f584/edit"
pinnedVersions:
- name: curlimages/curl
version: 8.11.1
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-3
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
같은 refresh token 다섯 개를 동시에 던져 회전 경쟁을 만들고, 이긴 요청이 받은 토큰과 그 세션의 client session 을 세는 절차다. BFF 를 거치지 않고 토큰 엔드포인트를 직접 치며, 되돌리기는 realm 설정 한 줄이다.
## 관계
- **회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다**
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 무엇이 부서졌는지는 그쪽이 적는다.
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
두 replica 가 같은 행을 본다는 것이 이 경쟁의 전제다. 그 전제를 만든 편이다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
다섯이 전부 `200` 일 때 그것이 「경쟁이 없었다」인지 「주입이 안 걸렸다」인지를 가르는 기준이다.
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
먼저 해 둬야 하는 편이다. 토큰이 공유되어야 경쟁이 성립한다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. 토큰을 주고받는 `curl` 만 탐침 파드 안에서 치는데, Keycloak 이미지에 `curl``wget` 도 없기 때문이다(`exit 127`). 브라우저는 필요 없다 — direct grant(`grant_type=password`)로 토큰을 만들므로 전 구간이 터미널에서 끝난다.
터미널은 둘을 연다. 하나는 탐침 파드 셸을 붙잡고 있고, 다른 하나로 데이터베이스를 뒤진다. 파드 안에서 잡은 `RT``SID` 는 파드 밖으로 따라가지 않는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| realm · 사용자 · 클라이언트 | `keycloak-patterns` · `labuser` / `labpass` · `bff-confidential` |
| 주입 수단 | `revokeRefreshToken=true` — realm 전체에 걸린다 |
| 탐침 파드 | `b3-probe``curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 치는 곳 | 토큰 엔드포인트를 직접. BFF 를 거치지 않는다 |
| 동시성 | 다섯. 셸의 `&``wait` 으로 만든다 |
| 전 구간 | 약 20분 |
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `sed` 로 자른다 |
## 이 실험이 가르는 것
B-2 가 토큰을 PostgreSQL 로 옮겼고 두 replica 가 같은 행을 본다. 조회 키에 세션 id 가 없으니 같은 사용자의 두 브라우저도 같은 행을 본다. 그 행에는 refresh token 이 하나 들어 있다. 둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.
통념은 하나가 성공하고 하나가 실패하며, 실패한 쪽은 새 토큰을 다시 읽어 재시도하면 된다고 본다. 이 절차는 진짜 그런지와, 이긴 쪽은 멀쩡한지를 잰다.
```text
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
```
그래서 재야 할 것은 몇 개가 성공했나가 아니라 **이긴 요청의 토큰을 다시 쓸 수 있나**다.
재사용 탐지(reuse detection)가 배경에 있다. 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화하는데, 무효화된 옛 토큰이 다시 들어오면 두 가지 중 하나다.
```text
① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
```
서버는 둘을 구별할 수 없다. 그래서 OAuth 2.0 보안 권고는 안전한 쪽으로 가정하고 세션 전체를 무효화하라고 말한다. 이 절차가 보는 파괴는 버그가 아니라 규격이 시키는 대로 동작한 결과이고, 그래서 답이 「고쳐 달라」가 아니라 「겹치지 않게 하라」가 된다.
끝까지 밟으면 다섯 중 하나만 `200` 이고 나머지가 `400` 인 것, 오류 문구가 두 종류인 것, 이긴 요청이 받은 토큰조차 못 쓰는 것, user session 은 남고 client session 만 사라진 것, `refreshTokenMaxReuse` 를 올려도 안 되는 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- `05-keycloak` · `06-observability` 가 끝나 있다.
- **B-2 가 끝나 있다.** 토큰이 공유되어야 경쟁이 성립한다. 다만 이 절차는 Keycloak 쪽 동작만 갈라 보려고 BFF 를 거치지 않고 토큰 엔드포인트를 직접 친다.
**realm 설정을 바꾸는 실험이다.** `revokeRefreshToken` 을 켜면 realm 전체에 걸리고, 같은 realm 을 쓰는 다른 작업이 영향을 받는다. B-2 의 BFF 로그인도 그 안에 든다. 실험대에서만 하고, 중간에 그만두려면 아래 한 줄이면 된다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
```
## 주입 전에 같은 명령으로 먼저 본다
시험군만 재는 측정은 측정이 아니다. 회전이 꺼진 상태에서 같은 명령을 먼저 돌려 두어야, 나중에 나오는 `400` 이 원래 그런 것인지 내가 켠 것 때문인지 갈린다.
```text
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
```
### 1. Keycloak 이 둘 다 Ready 인가
**무엇을 보는가** — 파드 셋의 상태와 배치.
```bash label="[kc-lab-1] 파드 배치를 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — 모양은 이렇다(observed).
```text
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 를 넘는 경쟁이라고 말할 수 없게 된다.
### 2. kcadm 에 로그인해 둔다
**목적** — realm 설정을 읽고 바꾸는 명령을 쓸 수 있게 한다.
① 파드 안에서 관리 세션을 만든다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 안 남는다.
```bash label="[kc-lab-1] kcadm 관리 세션을 만든다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**예상 결과** — 성공하면 아무것도 안 나온다.
**왜 필요한가** — 한 번 하면 파드 안에 세션이 남아 뒤의 `get` · `update` 가 전부 그것을 쓴다.
**문제가 생기면** — 비밀번호가 실제로 있는지는 값이 아니라 길이로 본다.
```bash label="[kc-lab-1] 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
### 3. realm 의 세 값을 읽는다
**무엇을 보는가** — 주입이 건드릴 스위치와 건드리지 않을 값.
```bash label="[kc-lab-1] realm 의 세 값을 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
**어디를 보나** — 실측은 이렇다(observed).
```json
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
| 값 | 뜻 | 지금 |
|---|---|---|
| `revokeRefreshToken` | 회전 스위치 | `false` — 꺼져 있다 |
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
| `accessTokenLifespan` | access token 수명(초) | `60` |
**이 값이 뜻하는 것** — 기본값은 회전이 꺼져 있다. 「회전과 재사용 허용 0회를 쓰는 realm」이 재려는 상태이므로, 그 상태를 만드는 것이 이 절차의 주입이다. 지금 그대로 재면 다른 것을 재게 된다. `accessTokenLifespan=60` 은 B-0 이 이 실험을 위해 넣어 둔 값이고, 만료를 기다리는 시간이 짧아야 재현이 된다. 이 값은 주입이 끝난 뒤에도 `60` 이어야 한다.
### 4. 상주 탐침 파드를 띄운다
**목적** — 발급받은 토큰을 다음 단계로 넘길 수 있는 셸을 만든다.
`--rm` 임시 파드는 매번 만들고 지우므로 토큰을 단계 사이로 못 넘긴다. 이 실험은 앞 단계에서 받은 토큰을 뒤 단계에서 써야 하므로 파드를 하나 띄워 두고 `exec` 로 이어간다.
**중간에 그만뒀다가 다시 시작하는 것이면 먼저 지운다.** `b3-probe` 라는 이름이 이미 있으면 아래 `run` 은 그 이름이 이미 있다며 거절하고, 남아 있는 파드가 들고 있는 `KC` 와 `CS` 는 지난번에 넣은 값이다. 지우는 명령은 이 절 끝 「문제가 생기면」에 있다.
① 파드를 띄우고 Ready 까지 기다린다.
```bash label="[kc-lab-1] ① 상주 탐침 파드를 띄운다"
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=$(kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
```
② 환경변수가 들어갔는지 값이 아니라 길이로 본다.
```bash label="[kc-lab-1] ② 넘어간 값의 길이를 센다"
kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
```
③ 파드 셸로 들어간다. 프롬프트가 `/ $` 로 바뀐다.
```bash label="[kc-lab-1] ③ 파드 셸로 들어간다"
kubectl -n keycloak-lab exec -it b3-probe -- sh
```
**예상 결과** — ①은 `pod/b3-probe condition met`, ②는 아래 모양이다(observed).
```text
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
```
**왜 필요한가** — 요청이 Service 로 간다. A-1·A-2 는 어느 노드가 답했나가 질문이라 파드 IP 로 직접 쳤지만, 여기는 replica 를 넘는 경쟁이 질문이므로 Service 가 요청을 흩는 것이 오히려 필요한 조건이다. `--rm` 이 없으므로 `exit` 해도 파드는 안 지워지고, 지우는 명령은 복구 절에 있다.
**문제가 생기면** — `CS길이=0` 이면 `--env` 가 빈 값을 넘겼다. 파드를 지우고 다시 띄운다.
```bash label="[kc-lab-1] 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
### 5. 토큰을 하나 받는다
**무엇을 보는가** — 응답에 무엇이 들어 있는지. 나중에 걸러 보려면 먼저 통째로 봐야 한다.
```sh label="[탐침 파드] ① 응답을 통째로 본다"
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 이 나온다(모양은 observed).
```json
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
```
`expires_in` 이 60 이다 — 앞에서 읽은 `accessTokenLifespan` 그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름이나 비밀번호다.
**다음에 쓸 값을 변수에 담는다.** 원래 실행도 이 형태였다(observed).
```sh label="[탐침 파드] ② 변수에 담고 sid 를 뽑는다"
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"
```
실측은 토큰 길이 `811`, jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c`, sid `BvFiB01Rntz1FcLdf7zG4BNt` 다(observed).
**`SID` 를 종이에 적어 둔다.** 관찰 절에서 데이터베이스를 뒤질 때 이 값이 필요하고, 그때는 파드 밖이라 변수가 안 넘어간다.
`sid` 가 빈 줄로 나오면 base64 패딩이나 base64url 문자(`-` `_`) 때문이다. 위 ②는 패딩만 채우고 아래 줄은 base64url 문자만 바꾸므로, 둘 중 하나씩만 고치는 셈이다. 둘을 한 줄에 같이 넣은 형태는 원본 가이드에 없다(unknown). 아래 형태로 페이로드 전체를 찍고 그 안에서 `"sid"` 를 눈으로 찾아 손으로 옮기는 것이 이 문서에 있는 방법이다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
```sh label="[탐침 파드] sid 가 안 나올 때 페이로드를 통째로 찍는다"
echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
### 6. 대조군 하나 — 순차로 다섯 번 갱신한다
**무엇을 보는가** — 겹치지 않으면 무슨 일이 일어나는지. 파드 안에서 `&` 없이 친다.
```sh label="[탐침 파드] 순차로 다섯 번 갱신한다"
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` 이어야 한다. 증거 파일에는 순차 실행 기록이 없고(unknown), 해설 문서가 순차 실행이면 재현되지 않는다고 말한다. 따라 하는 사람이 자기 손으로 확인하는 순서다.
**이 값이 뜻하는 것** — 루프가 갱신마다 `RT` 를 다시 담는다. 회전이 켜지면 옛 것을 계속 쓸 수 없고, 그대로 두면 뒤에 나오는 `400` 이 경쟁 때문인지 옛 토큰을 썼기 때문인지 갈리지 않는다. 이 실험에서 가장 흔한 자기오염이다.
### 7. 대조군 둘 — 경쟁을 겪지 않은 세션의 모양
**무엇을 보는가** — 정상 세션의 `client_sessions` 가 몇인가. 파드 밖에서 친다.
```bash label="[kc-lab-1] 정상 세션의 client session 을 센다"
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'"
```
**어디를 보나** — 실측은 이렇다(observed, `03-client-session-removed.txt`).
```text
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
새 sid: JT-XuepgutWcE273QwAnIXta
user_session_id | client_sessions
--------------------------+-----------------
JT-XuepgutWcE273QwAnIXta | 1
(1 row)
```
**이 값이 뜻하는 것** — `client_sessions = 1` 이 정상 세션의 모양이다. 위 질의의 sid 는 원래 실행의 값이므로 따라 하는 사람은 자기 `SID` 를 넣는다. 안 바꾸고 치면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 이 대조군이 없으면 나중에 나오는 `0` 이 경쟁 때문인지 원래 그런 표인지 모른다.
user session 과 client session 은 서로 다르다.
```text
user session "이 브라우저는 labuser 로 로그인함"
├─ client session : bff-confidential
└─ client session : oauth2-proxy
```
사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 user session 하나 아래에 client session 이 여럿 달린다. 그게 SSO 다. 재사용 탐지는 이 중 client session 만 제거한다. 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데, `offline_flag` 열이 그것을 가른다 — 위 출력의 `offline_flag = 0` 이 온라인 세션이다.
## 주입
**목적** — 회전과 재사용 허용 0회를 켠다.
`revokeRefreshToken` 이 회전 스위치다. 이름이 회전(rotation)이 아니라 취소(revoke)인데, 켜면 새 토큰을 줄 때 옛 토큰을 무효화하고 그 결과가 회전이다.
| 설정 | 뜻 |
|---|---|
| `revokeRefreshToken` | 회전 스위치. 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
| `refreshTokenMaxReuse` | 그 위에서 몇 번까지 봐줄 것인가 |
`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다. 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니 봐줄 횟수를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색으로 보이는 까닭도 거기 있다.
① 회전을 켜고 시각을 남긴다.
```bash label="[kc-lab-1] 회전을 켜고 시각을 남긴다"
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 회전 켬'
```
**예상 결과** — 성공하면 아무 말도 안 하고 시각만 찍힌다(모양은 observed).
```text
14:16:12 회전 켬
```
**왜 필요한가** — 관찰 절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다. 켜기 전에 발급한 토큰으로 재면 발급 시점의 정책이 아니라 검증 시점의 정책이 적용되어 섞이고, 그러면 해석이 안 된다.
**문제가 생기면** — 주입 검증으로 넘어가 설정을 다시 읽는다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 설정은 똑같은 명령으로 다시 읽는다.
```bash label="[kc-lab-1] ① realm 을 똑같은 명령으로 다시 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
모양은 이렇다(observed).
```json
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
`revokeRefreshToken` 이 `true` 여야 한다. `false` 그대로면 `update` 가 다른 realm 에 갔거나 kcadm 세션이 만료됐다. kcadm 은 실패해도 조용할 때가 있어 반드시 다시 읽어서 확인한다.
```bash label="[kc-lab-1] ② 재시작이 올랐는지 본다"
kubectl -n keycloak-lab get pods -l app=keycloak
```
`RESTARTS` 가 여전히 0 이어야 한다. realm 설정 변경은 재시작을 일으키지 않으므로, 여기서 재시작이 올랐다면 다른 것을 건드렸다. 그 상태로 재면 경쟁이 아니라 재시작을 재게 된다.
**동시성을 넣기 전에 회전 자체가 도는지 확인한다.** 새 토큰을 하나 받고 한 번 갱신한 뒤 옛 것을 다시 쓴다. 가이드는 이 단계를 증거 파일에 없는 사전 확인이라고 적는다(unknown).
```sh label="[탐침 파드] ③ 회전이 실제로 도는지 두 번 쳐서 본다"
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` 이면 회전이 안 켜진 것이고, 그 상태로 관찰 절을 돌리면 다섯 개가 전부 `200` 으로 나온다 — 그건 경쟁이 없었다는 뜻이 아니라 주입이 안 걸렸다는 뜻이다.
## 관찰
사전 확인에서 쓴 토큰은 이미 무효다. 깨끗한 토큰을 하나 새로 받고 `SID` 를 다시 적어 둔다.
```sh label="[탐침 파드] ① 깨끗한 토큰을 새로 받는다"
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"
```
**동시에 다섯 개를 던진다.** 원래 실행은 스크립트였고, 아래는 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시한 형태다(unknown). 본문과 응답 코드를 파일로 갈라 순서대로 다시 읽게 했다.
```sh label="[탐침 파드] ② 같은 토큰으로 동시에 다섯 번 갱신한다"
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
```
셸 문법 세 조각이 전부다.
```text
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
wait 띄운 것이 전부 끝날 때까지 기다린다
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
```
`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다. `wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다. 다섯 개가 같은 터미널에 동시에 쓰면 어느 줄이 어느 요청인지 알 수 없어서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
실측은 이렇다(observed, `01-concurrent-refresh.txt`).
```text
=== [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 이 이미 없다 |
하나만 이기고 나머지가 진 것이라면 지는 쪽 메시지가 전부 같아야 한다. 두 종류라는 것은 중간에 상태가 바뀌었다는 뜻이다. 성공한 번호는 환경마다 다르고 증거에서는 5번이었지만 순서는 스케줄링이 정한다 — 몇 번이 이겼는가는 아무 의미가 없다.
**이긴 요청의 토큰을 다시 써 본다.** 여기서 진짜 답이 나온다.
```sh label="[탐침 파드] ③ 이긴 요청이 받은 토큰을 꺼내 다시 쓴다"
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"
```
실측은 이렇다(observed, `02-session-impact.txt`).
```text
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
새 refresh token 길이: 810
그 토큰으로 다시 갱신: HTTP 400
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
```
이긴 요청조차 쓸 수 없는 토큰을 받았다.
```text
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
```
오류가 지연되어 나타난다. `200` 을 받은 코드는 성공했다고 믿고 토큰을 저장하고, 끊긴 것은 그다음 요청에서 안다. 로그를 볼 때 원인 시각과 증상 시각이 어긋나 보이는 까닭도 여기 있다. 재시도하면 되지 않나가 여기서 무너진다 — 새 토큰을 다시 읽어 재시도해도 그 토큰이 이미 무효라 재시도할 대상이 없다.
**무엇이 사라졌는지는 데이터베이스가 말한다.** 파드 밖에서 치고, sid 는 앞에서 적어 둔 값을 넣는다.
**아래 두 블록에 박힌 `'BvFiB01Rntz1FcLdf7zG4BNt'` 를 자기 `SID` 로 바꾼다.** 그것은 원래 실행의 sid 라, 그대로 붙여넣으면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 두 블록 모두 바꿔야 한다 — 한쪽만 바꾸면 두 출력이 서로 다른 세션을 말한다. 출력은 마지막 줄부터 읽는다. `(1 row)` 면 그 sid 의 세션을 찾았고, `(0 rows)` 면 sid 를 안 바꿨거나 다른 값을 넣었다.
```bash label="[kc-lab-1] ④ 그 sid 의 세션이 남아 있는가"
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'"
```
실측은 이렇다(observed, `02-session-impact.txt`).
```text
=== [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 label="[kc-lab-1] ⑤ 같은 sid 의 client session 을 센다"
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'"
```
실측은 이렇다(observed, `03-client-session-removed.txt`).
```text
=== user session 과 client session 을 나눠서 본다 ===
user_session_id | offline_flag | client_sessions
--------------------------+--------------+-----------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
(1 row)
```
**`(0 rows)` 와 위 출력의 `client_sessions 0` 은 다른 답이다.** 앞은 그 sid 의 행을 아예 못 찾았다는 뜻이고, 뒤는 행을 찾았는데 그 안의 개수가 0 이다. 화면에서 `0` 두 개가 비슷해 보이지만 판정은 뒤에서만 나온다.
`client_sessions = 0` 이고 대조군은 `1` 이었다. 같은 명령에 다른 결과가 나온 것이 이 실험의 판정이다.
```text
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
```
오류 문구가 정확히 그 말을 한다 — 세션은 있는데 그 클라이언트 몫이 없다. 메시지를 오해해서 세션이 만료됐다로 읽으면 엉뚱한 곳을 고치게 된다.
폐기 목록에 실린 것도 아니다.
```bash label="[kc-lab-1] ⑥ 폐기 목록을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select count(*) as revoked_count from revoked_token"
```
실측은 이렇다(observed, `02-session-impact.txt`).
```text
=== [5] revoked_token 테이블 ===
revoked_count
---------------
0
(1 row)
```
`0` 이다. 토큰을 블랙리스트에 올려서 막은 것이 아니라 client session 이 사라져서 검증할 대상이 없어졌다. 토큰을 지우는 방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 그 client 에 대한 모든 토큰이 한꺼번에 죽는다.
왜 이긴 쪽도 죽는지는 시간선이 말한다.
```text
t0 5개가 동시에 도착
t1 하나가 처리를 시작 → 새 토큰 발급 준비
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
t3 ★ client session 제거
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
t5 그 토큰을 쓰면 → client session 이 없다 → 400
```
t3 와 t4 의 순서가 전부다. 응답을 만들던 요청은 이미 성공이 확정된 상태로 나가고, 그 사이 바닥이 빠진다.
**정책을 바꿔 두 번 더 잰다.** 한 번 더 재기 전에 세션을 새로 만든다 — 파괴된 세션으로 재면 전부 `400` 이다.
```bash label="[kc-lab-1] ⑦ 구성 B — 회전을 끈다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false
```
관찰 절의 ① 새 토큰 발급 → ② 동시 다섯 개 → ③ 이긴 토큰 재사용 → ⑤ client session 세기를 그 순서대로 다시 친다. ①·②·③ 은 탐침 파드 셸 안에서 치고 ⑤ 는 파드 밖에서 친다. 스크롤백을 되돌려 치는 것으로는 안 된다 — ①이 새 `SID` 를 만들고, ⑤의 질의에는 방금 만든 그 값을 넣어야 한다.
```text
=== 구성 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` 이고 세션도 멀쩡하다(observed, `04-policy-comparison.txt`). 같은 refresh token 을 계속 쓸 수 있으므로 경쟁 자체가 성립하지 않는다. 대신 잃는 것이 있다 — 토큰이 유출되면 만료까지 계속 쓸 수 있고, 회전의 목적이 그 창을 좁히는 것이었다.
```bash label="[kc-lab-1] ⑧ 구성 C — 회전을 켜고 재사용 1회를 허용한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
```
```text
=== 구성 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` 은 그대로다(observed, 같은 파일).
| 구성 | 성공 | 이긴 토큰 재사용 | 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` 이어야 하는데, 그러면 회전의 보안 목적이 사라진다. 값을 올려 버티려는 시도는 몇 개까지 동시에 올 것인가를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
그래서 답은 잠금이고, 잠금은 저장소 쪽에 있어야 한다 — 프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
| 후보 | |
|---|---|
| PostgreSQL 행 잠금 | `SELECT ... FOR UPDATE` — A-0 에서 Keycloak 자신이 쓰는 방식 |
| Redis 분산 잠금 | `SET NX PX` — TTL 로 스스로 풀린다 |
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
데이터베이스 잠금은 잠금의 수명이 연결의 수명과 묶인다. 프로세스가 죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis 잠금은 TTL 이 짧으면 중복 갱신, 길면 정지이고, 그 약점은 B-5 에서 다시 만난다.
## 복구와 원상복구 확인표
### 1. realm 설정을 되돌린다
**목적** — 같은 realm 을 쓰는 다른 작업이 회전을 물려받지 않게 한다.
① 두 값을 한 번에 되돌리고 시각을 남긴다.
```bash label="[kc-lab-1] ① 회전을 끄고 시각을 남긴다"
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 회전 끔'
```
② 똑같은 명령으로 다시 읽는다.
```bash label="[kc-lab-1] ② 되돌아갔는지 다시 읽는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
**예상 결과** — 주입 전에 읽은 세 값과 전부 같아야 한다(모양은 observed).
```json
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
**왜 필요한가** — 구성 C 에서 `refreshTokenMaxReuse` 를 `1` 로 올렸으므로 그것까지 같이 되돌려야 한다. `accessTokenLifespan` 이 60 이 아니면 다른 것도 건드렸다.
**문제가 생기면** — kcadm 세션이 만료됐을 수 있다. `config credentials` 를 다시 친다.
### 2. 탐침 파드를 지우고 세션을 정리한다
**목적** — 실험 도구를 치우고 파괴된 세션을 남기지 않는다.
① 파드를 직접 지운다. `--rm` 이 없으므로 자동으로 사라지지 않는다.
```bash label="[kc-lab-1] ① 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
② 파괴된 세션의 행은 TTL 로 스스로 사라진다. 바로 치우고 싶으면 브라우저에서 아래를 연다. 이 실험은 여기까지 재지 않았다(unknown).
```text
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
```
③ 세션 수를 센다.
```bash label="[kc-lab-1] ③ 세션 수를 센다"
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` 이 안 되어도 놀랄 일이 아니다.
**왜 필요한가** — 다음 실험에서 `b3-probe` 이름이 이미 있다고 거절당하는 것을 막는다.
**문제가 생기면** — 파드가 `Completed` 로 남아 있으면 같은 `delete` 를 다시 친다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| realm | 위 `get realms/...` | `revokeRefreshToken : false` |
| 파드 | `kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 탐침 | `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` 이 있는지 |
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 주입 검증을 다시. 사전 확인이 `200/400` 이어야 한다 |
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 |
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 |
| `CS길이=0` | secret 이름이나 키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 |
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 |
| 데이터베이스 질의에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
| 파드 셸에 다시 들어갔더니 변수가 없다 | 그 `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 |
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 realm 을 되돌린다 |
부하 도구가 없는 것도 설계다. 동시성 5는 `ab` 도 `k6` 도 필요 없고 셸의 `&` 와 `wait` 이면 충분하며, 그 편이 무엇이 일어났는지 더 잘 보인다 — 요청 다섯 개의 본문을 전부 파일로 갖고 있으니 나중에 다시 읽는다. 부하 도구는 개수를 늘려야 할 때 쓴다. 이 절차가 묻는 것은 개수가 아니라 겹치면 무엇이 부서지는가이고, 그건 둘만 겹쳐도 답이 나온다. 다섯 개를 쓴 것은 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지 다섯이 필요해서가 아니다.
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:1614:17 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 주입 전 realm 의 세 값 `{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }`, 토큰 길이 `811` 과 jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c` 와 sid `BvFiB01Rntz1FcLdf7zG4BNt`, 동시 다섯 요청의 상태 코드와 오류 문구 두 종류, 이긴 토큰의 길이 `810` 과 그 토큰으로 다시 갱신했을 때의 `HTTP 400`, 그 sid 의 `offline_flag 0` · `last_session_refresh 1788498996` · `client_sessions 0`, 대조군 세션 `JT-XuepgutWcE273QwAnIXta` 의 `client_sessions 1`, `revoked_count 0`, 구성 B·C 의 sid 와 다섯 코드와 `남은 client_session` 값, `CS길이=15`.
- (unknown) `&` 와 `wait` 으로 다섯을 동시에 띄우는 while 루프, sid 를 base64url 로 다시 푸는 줄, 순차 다섯 번 갱신 루프, 회전이 도는지 보는 사전 확인 두 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트로 했다. 순차 실행은 증거 파일에 기록 자체가 없다.
- 이 실험이 재지 않은 것 — BFF 를 거쳐 같은 경쟁이 나는지는 재지 않았다. 여기서는 Keycloak 쪽 동작만 갈라 보려고 토큰 엔드포인트를 직접 쳤다. RP-initiated logout 으로 파괴된 세션을 치우는 것도, 동시성을 5보다 늘리면 어떻게 되는지도 재지 않았다.
- 추론이지 측정이 아닌 것 — `refreshTokenMaxReuse ≥ N-1` 이어야 한다는 것은 A·C 두 구성에서 관측한 결과에서 따라 나온 것이고, N 을 바꿔 가며 재 보지는 않았다. 잠금 후보 셋도 어느 것을 넣어 재현이 사라지는지 재지 않았다 — 이 실험은 무엇이 부서지는가까지다.
<!-- body:end -->
@@ -0,0 +1,758 @@
---
id: af9645a0-8ca8-481d-9624-69fce6449b7c
kind: SETUP
slug: reproduce-b5-redis-loss
title: Redis 를 0대로 내리고 파드가 Ready 를 유지하는지 본다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/af9645a0-8ca8-481d-9624-69fce6449b7c/edit"
pinnedVersions:
- name: Redis
version: 7.4.x
- name: netty-transport
version: 4.1.135.Final
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-5
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# Redis 를 0대로 내리고 파드가 Ready 를 유지하는지 본다
Redis 를 0대로 내렸을 때 무엇이 멈추는지 재는 절차다. 세 경로와 health 그룹과 Service 엔드포인트를 보고, 이어서 볼륨을 뗀 채 파드를 지워 영속화 설정만으로 무엇이 남는지 본다. 세션은 돌아오지 않으므로 실험대에서만 하고, 되돌리려면 매니페스트를 다시 적용한다.
## 관계
- **볼륨 없는 영속화와 유예 없는 키 회전**
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
A-2 는 readiness 가 파드를 뺐고 여기는 안 뺀다. 무엇이 그 차이를 만드는지 다룬다.
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
파드가 `Ready` 인 채로 계속 실패하는 동안 지표가 무엇을 말하는지 다룬다.
- **PostgreSQL 을 정상 종료시키고 네 경로를 잰다**
같은 모양의 실험을 Keycloak 쪽에서 한 편이다. 두 결과를 견주는 것이 이 절차의 결론이다.
- **Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다**
먼저 해 둬야 하는 편이다. 세션이 Redis 에 있어야 잃는 것이 보인다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. `kubectl``sudo` 를 붙이지 않는다 — root 홈에는 kubeconfig 가 없어 `localhost:8080` 으로 붙으려다 끝난다. 브라우저는 시작 전에 한 번 쓴다. 세션이 Redis 에 하나는 있어야 잃는 것이 보인다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 주입 수단 ① | `scale deployment/redis --replicas=0` — 없는 상태가 유지된다 |
| 주입 수단 ② | `volumeMounts``volumes` 를 patch 로 떼고 파드를 지운다 |
| Redis | `redis.keycloak-lab.svc:6379`, 파드는 `kc-lab-2` 에 고정 |
| 재는 경로 | 셋 — `/` · `/bff/token-boundary` · `/actuator/health` |
| 전 구간 | 약 30분 |
| 잃는 것 | 로그인 세션. 돌아오지 않는다 |
**이 실험대는 그 뒤로 바뀌었다.** 지금 매니페스트(`bff-redis.yaml`)에는 B-5 의 결론이 이미 반영되어 PVC 와 `--appendonly yes` 가 들어 있다. 그래서 둘째 주입은 볼륨 없는 상태를 다시 만드는 단계부터 시작한다. 원래 실행은 반대 순서였다 — 볼륨 없는 상태에서 시작해 PVC 를 붙였다(unknown).
## 이 실험이 가르는 것
A-2 에서 Keycloak 의 PostgreSQL 을 내렸을 때는 이렇게 됐다.
```text
DB 정지 → 헬스체크 실패 → 파드 NotReady → Service 에서 빠짐 → 밖에서 503
```
명확한 실패였다. `503` 은 지금 안 된다고 말하고, 클라이언트는 재시도든 포기든 정할 수 있다. 통념은 의존 저장소가 죽으면 헬스체크가 알아서 파드를 빼 준다는 것이고, 이 절차는 진짜 그런지와 이번에는 무엇을 보고 판단하는지를 잰다.
두 번째 질문이 붙는다.
```text
Redis 를 다시 띄우면 → 세션이 남아 있나?
```
영속화를 켜 두면 된다는 통념이 쿠버네티스에서 어떻게 어긋나는지를 잰다. 그래서 영속화를 논하기 전에 `/data` 가 무엇인지부터 보는 절이 이 절차에서 가장 무겁다.
## 전제와 되돌리기
- `05-keycloak` · `06-observability` 가 끝나 있다.
- B-1 · B-2 가 끝나 세션은 Redis, 토큰은 PostgreSQL 로 나뉘어 있다. 나뉘어 있어야 각각 죽여볼 수 있고, 이 절차는 Redis 만 죽인다.
- 브라우저로 `https://app1.hyeonworks.com/` 에 로그인해 둔다(`labuser` / `labpass`).
**저장소를 지우는 실험이다.** Redis 를 0대로 내리고 나중에 볼륨 없이 파드를 지운다. 그 안의 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다. 중간에 그만두려면 한 줄이면 된다.
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
kubectl -n keycloak-lab scale deployment/redis --replicas=1
```
볼륨을 뗀 뒤에는 매니페스트를 다시 적용해 되돌린다. 그 두 줄이 둘째 주입의 유일한 되돌리기다. `deploy/lab/k8s/bff-redis.yaml` 은 저장소 안의 상대 경로다 — 저장소를 체크아웃한 디렉터리에서 쳐야 풀리고, 다른 디렉터리에서 치면 경로가 없다는 오류로 끝나 볼륨이 안 돌아온다. 그 체크아웃이 `kc-lab-1` 의 어디에 있는지는 원본 가이드에 없다(unknown).
```bash label="[kc-lab-1] 볼륨을 뗀 뒤에 되돌리는 두 줄"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
## 주입 전에 같은 명령으로 먼저 본다
```text
파드 → Redis 내용 · 영속화 설정 → ★ /data 가 볼륨인가 → 세 경로 → health 그룹
```
### 1. 파드가 어디에 몇 개 있는가
**무엇을 보는가** — 파드 넷의 상태와 배치.
```bash label="[kc-lab-1] 파드 배치를 본다"
kubectl -n keycloak-lab get pods -o wide
```
**어디를 보나** — 모양은 이렇다(observed).
```text
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` 는 실제 BFF 파드 IP 이고, 관찰 절에서 이 두 주소가 다시 나온다.
### 2. Redis 안에 무엇이 있고 영속화가 어떻게 설정돼 있는가
**무엇을 보는가** — 키 수와 두 영속화 설정.
```bash label="[kc-lab-1] Redis 의 내용과 영속화 설정을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get save
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
```
**어디를 보나** — 실측은 이렇다(observed, `01-baseline.txt`).
```text
=== 기준선 ===
Redis 키: 1
PostgreSQL 토큰: 1 행
Redis 영속화 설정:
save = save
appendonly no
```
| 값 | 그때 | 뜻 |
|---|---|---|
| 키 수 | `1` | 로그인 세션 하나 |
| `save` | 빈 값 | RDB 스냅샷이 꺼져 있다 |
| `appendonly` | `no` | AOF(append-only file, 쓰기를 순서대로 적어 두는 파일)도 꺼져 있다 |
**이 값이 뜻하는 것** — 그때는 영속화가 아예 꺼져 있었다. 지금 환경은 아마 다르다 — 매니페스트가 `--appendonly yes` 로 시작하므로 `appendonly yes` 가 나오고, 그 차이가 둘째 주입의 출발 조건이다.
`save` 출력의 값이 비어 있는 것과 그 설정 자체가 없는 것은 다르다. `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면 스냅샷 조건이 없다는 뜻이다. 증거의 `save = save` 는 그 두 줄이 한 줄로 붙어 찍힌 모양이다.
### 3. `/data` 가 볼륨인가
**무엇을 보는가** — 영속화를 말하기 전에 확인할 셋. 이 확인을 건너뛰면 「AOF 를 켰는데 안 남는다」를 「Redis 가 이상하다」로 읽게 된다.
```bash label="[kc-lab-1] ① 파드에 볼륨이 붙어 있는가"
kubectl -n keycloak-lab get pod -l app=redis \
-o jsonpath='{.items[0].spec.volumes}'; echo
```
지금 매니페스트 기준의 모양은 이렇다(observed).
```json
[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]
```
```bash label="[kc-lab-1] ② 어디에 붙었는지와 PVC 상태를 본다"
kubectl -n keycloak-lab get pod -l app=redis \
-o jsonpath='{.items[0].spec.containers[0].volumeMounts}'; echo
kubectl -n keycloak-lab get pvc
```
```text
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
redis-data Bound pvc-... 1Gi RWO local-path 3d
```
**어디를 보나** — 셋이 전부 성립해야 한다.
```text
① volumes 에 항목이 있다 ← 없으면 컨테이너 파일시스템이다
② volumeMounts 의 mountPath 가 /data ← 다른 데 붙었으면 소용없다
③ PVC 가 Bound ← Pending 이면 파드가 안 뜬다
```
**이 값이 뜻하는 것** — 하나라도 빠지면 `appendonly yes` 는 장식이다. 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.
```text
/data 가 볼륨이 아니다 → 이미지 위의 쓰기 가능 레이어에 쓴다
→ 컨테이너가 없어지면 그 레이어도 없어진다
```
Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에 `appendonlydir` 을 만들고 매 쓰기를 기록한다. 거짓말이 아니라 정말로 기록하고, 다만 그 디렉터리가 어디 있는지를 모른다. `emptyDir` 도 마찬가지다 — 컨테이너 재시작은 견디지만 파드가 없어지면 같이 없어진다. 볼륨을 붙였다와 영속 볼륨을 붙였다는 다르다.
### 4. 세 경로를 정상 상태에서 한 번 돌린다
**무엇을 보는가** — 주입 후에 볼 세 경로를 주입 전에 똑같은 명령으로.
```bash label="[kc-lab-1] 세 경로의 상태 코드를 뽑는다"
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
```
**어디를 보나** — 증거에는 첫 줄만 남았다(observed, `01-baseline.txt`).
```text
=== 외부 진입점 정상 확인 ===
https://app1.hyeonworks.com/ HTTP 200
```
`000` 이 아닌 것이 판정 기준의 전부다.
| 경로 | 정상일 때 | 왜 |
|---|---|---|
| `/` | `200` | `permitAll` 정적 페이지. Redis 를 안 탄다 |
| `/bff/token-boundary` | `200` 또는 로그인으로 보내는 `3xx` | 세션이 필요하다 — Redis 를 탄다 |
| `/actuator/health` | `200` | 모든 지표의 합 |
**이 값이 뜻하는 것** — 셸의 `curl` 에는 로그인 쿠키가 없으므로 두 번째는 보통 `3xx` 이고, 가이드가 이 대목을 미검증으로 표시했다(unknown). `200` 이든 `3xx` 든 상관없다 — 이 절차가 보는 것은 응답이 오는가이고, `3xx` 를 만드는 과정에서도 BFF 는 세션을 만들려고 Redis 를 건드린다.
**`--max-time` 을 반드시 붙인다.** 주입 뒤 이 요청은 응답이 안 온다. 타임아웃이 없으면 터미널이 붙잡힌 채로 있고, 그 상태를 멈춤이 아니라 내 터미널이 이상함으로 읽게 된다.
### 5. health 그룹 셋이 서로 다른지 본다
**무엇을 보는가** — 세 응답의 본문. `/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다).
```bash label="[kc-lab-1] ① health 그룹 셋의 본문을 받는다"
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` 항목이 있는지, 두 번째 응답에는 없는지를 본다. 세 응답이 서로 다르다는 것을 보는 것이 이 확인의 전부다.
정지 후 값이 증거에 이렇게 남아 있고, 그 본문 항목 칸은 비어 있다(observed, `03-health-groups.txt`).
```text
=== /actuator/health 본문 (Redis 항목이 있는가) ===
=== /actuator/health/readiness 본문 ===
{"status":"UP"}
```
**첫 칸이 비어 있는 것은 측정 실패다.** 파드 안에서 본문을 받아오려다 못 받았다. 밖에서 직접 재 두는 편이 낫다 — 뒤에서 이 값을 비교하게 된다.
```text
/actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
/actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
/actuator/health/liveness liveness 그룹
```
```bash label="[kc-lab-1] ② kubelet 이 보는 경로를 확인한다"
kubectl -n keycloak-lab get deploy bff \
-o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}'; echo
```
```text
/actuator/health/readiness
```
**이 값이 뜻하는 것** — `redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다. 그리고 kubelet 이 보는 것은 매니페스트가 지정한 경로다. 전체는 DOWN 인데 readiness 는 UP 인 상태가 성립한다.
## 주입
주입은 둘이다. 첫째는 Redis 를 0대로 내리고, 둘째는 볼륨을 뗀 채 영속화만 켜고 파드를 지운다. **둘째는 첫째를 되돌린 뒤에 한다.**
내리는 방법을 고른 이유부터 본다.
| 방법 | 만들어지는 상태 |
|---|---|
| `delete pod` | Deployment 가 **즉시 새로 만든다.** 몇 초짜리 공백이라 관찰할 시간이 없다 |
| **`scale --replicas=0`** | **없는 상태가 유지된다.** 내가 되돌릴 때까지 |
| NetworkPolicy 로 6379 차단 | 「연결 거부」와 「응답 없음」이 섞인다. A-1 에서 본 대로 기존 연결은 안 끊긴다 |
저장소가 없어진 상태를 안정적으로 유지하는 것이 목적이므로 두 번째를 쓴다. 파드가 사라지므로 주입 여부를 눈으로 확인하기도 쉽다.
### 1. Redis 를 0대로 내린다
**목적** — Redis 가 없는 상태를 만들고 그 상태를 유지한다.
① 시각을 남기고 replica 를 0으로 내린다.
```bash label="[kc-lab-1] 시각을 남기고 Redis 를 0대로 내린다"
date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/redis --replicas=0
```
**예상 결과** — 실측은 이렇다(observed, `02-redis-down.txt`).
```text
=== ① Redis 정지 ===
정지: 14:26:30
deployment.apps/redis scaled
삭제 완료
```
**왜 필요한가** — 시각을 반드시 적어 둔다. 언제부터 회복됐나를 붙일 때 쓴다.
**문제가 생기면** — 파드가 몇 초 만에 돌아왔다면 `delete pod` 를 쳤다. `scale --replicas=0` 으로 다시 한다.
### 2. 볼륨을 떼고 영속화만 켠다
**목적** — 영속화 설정은 켜져 있고 `/data` 는 컨테이너 파일시스템인 상태를 만든다. 첫째 주입을 되돌린 뒤에 한다.
**⓪ 먼저 Redis 를 다시 올린다.** 바로 앞 절에서 0대로 내려 두었고, 이 절의 명령은 전부 파드가 살아 있어야 한다. 올리지 않고 이어 치면 `exec deploy/redis` 가 붙을 파드를 못 찾는다.
```bash label="[kc-lab-1] ⓪ 앞 절의 주입을 되돌린다"
kubectl -n keycloak-lab scale deployment/redis --replicas=1
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
① `volumeMounts` 와 `volumes` 를 함께 뗀다. 가이드가 이 방향을 미검증으로 표시했다(unknown) — 원래 실행은 반대 순서였다.
```bash label="[kc-lab-1] ① 볼륨 참조를 떼고 롤아웃을 기다린다"
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"}]'
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
② AOF 를 켜고 키를 심은 뒤 `/data` 를 본다.
```bash label="[kc-lab-1] ② AOF 를 켜고 키를 심는다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- ls -la /data
```
**예상 결과** — `appendonly yes` 가 나오고 `/data` 에 `appendonlydir` 이 생긴다.
**왜 필요한가** — **PVC 자체는 지우지 않는다.** Deployment 에서 참조만 뗐고, 나중에 `apply` 로 되돌리면 같은 PVC 에 다시 붙는다. PVC 를 지우면 `local-path` 프로비저너가 노드의 디렉터리까지 지운다.
**문제가 생기면** — 패치가 안 먹으면 `spec.volumes` 가 여전히 PVC 를 보여 준다. 주입 검증에서 그것부터 본다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다.
**네 확인이 같은 시점을 보지 않는다.** ①②③ 은 첫째 주입이 걸려 있는 동안에만 성립한다 — 주입 1 절을 친 직후, 주입 2 절의 ⓪ 으로 Redis 를 다시 올리기 전에 본다. ④ 는 둘째 주입을 친 뒤라 Redis 가 1대로 살아 있을 때 본다. 절 순서대로 위에서 아래로 한 번에 치면 ① 이 `1/1` 을 내는데, 그것은 스케일이 안 먹은 증상이 아니라 ⓪ 이 제대로 올린 결과다.
```bash label="[kc-lab-1] ① Redis 가 0대인가"
kubectl -n keycloak-lab get pods -l app=redis
kubectl -n keycloak-lab get deploy redis
```
모양은 이렇다(observed).
```text
No resources found in keycloak-lab namespace.
NAME READY UP-TO-DATE AVAILABLE AGE
redis 0/0 0 0 3d
```
`0/0` 이어야 한다. `1/1` 이면 스케일이 안 먹었거나 다른 네임스페이스를 건드린 것이고, 그 상태에서 재는 것은 전부 무의미하다.
**응답이 없는 것과 붙지 못하는 것은 다르다.** 로그가 이유를 말한다.
```bash label="[kc-lab-1] ② BFF 로그에서 연결 시도를 찾는다"
kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|netty' | tail -10
```
실측은 이렇다(observed, `02-redis-down.txt`).
```text
=== 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 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. 관찰 절의 `000` 이 여기서 나온다.
```bash label="[kc-lab-1] ③ 엉뚱한 것을 죽이지 않았는지 본다"
kubectl -n keycloak-lab get pods
```
```text
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
bff-555df79c97-6j86w 1/1 Running 0 17m
bff-555df79c97-vgg6g 1/1 Running 0 16m
```
`bff` 두 개의 `RESTARTS` 가 여전히 0 이고 postgres 가 살아 있어야 한다(observed). postgres 까지 내렸다면 B-5 가 아니라 전면 장애를 재게 된다. 여기서 이미 답이 절반 나와 있다 — Redis 가 없는데 `1/1` 이다.
**둘째 주입도 걸렸는지 본다.** 볼륨 확인은 주입 전과 똑같은 명령이다.
```bash label="[kc-lab-1] ④ 볼륨이 정말 떨어졌는가"
kubectl -n keycloak-lab get pod -l app=redis \
-o jsonpath='{.items[0].spec.volumes}'; echo
```
**빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 당연히 살아남는다 — 그리고 그걸 영속화가 잘 된다고 오독한다.
**빈 줄에는 뜻이 둘이다.** 이 명령은 `app=redis` 라벨이 붙은 파드 중 첫째를 골라 그 파드의 `spec.volumes` 를 찍는다. Redis 가 0대면 고를 파드가 없어 아무것도 안 나오고, 그 화면은 볼륨을 뗐을 때와 구별되지 않는다. 그래서 이 줄을 읽기 전에 위 ① 의 `get pods -l app=redis` 로 Redis 파드가 하나 `Running` 인지부터 본다. 파드가 있는데 빈 줄이면 볼륨이 떨어졌고, 파드가 없으면 이 명령은 아직 아무 말도 하지 않았다.
```text
--- 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` 이 실제로 만들어졌다(observed, `04-persistence.txt`). Redis 는 시킨 대로 했다 — 설정도 `yes` 고 디렉터리도 있고 파일도 쓰인다. **여기서 영속화가 켜졌다고 결론 내리면 틀린다.** 어디에 쓰는지를 안 봤기 때문이고, 지금 `/data` 는 컨테이너 파일시스템이다.
## 관찰
**`000` 은 오류가 아니라 멈춤이다.** 주입 전과 똑같은 명령을 친다.
```bash label="[kc-lab-1] 세 경로를 다시 친다"
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
```
실측은 이렇다(observed, `02-redis-down.txt`).
```text
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
/ HTTP 200
/bff/token-boundary HTTP 000
/actuator/health HTTP 503
```
| 코드 | 뜻 |
|---|---|
| `200` | 정적 페이지는 산다 — Redis 를 안 타는 경로 |
| **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 |
| `503` | 헬스 엔드포인트는 대답은 한다 — 다만 DOWN 이라고 |
오류를 돌려주는 것이 아니라 매달려 있다.
```text
빠른 실패: 요청 → 즉시 503 → 사용자는 오류 화면을 본다. 재시도할지 정할 수 있다
느린 실패: 요청 → ………… → 사용자는 멈춘 화면을 본다. 아무것도 정할 수 없다
```
빨리 실패하기(fail fast)가 안 되어 있다. A-6(지연 주입)에서 본 것과 같은 문제이고, 브라우저 탭도 그 앞의 로드밸런서도 그 앞의 사용자도 전부 붙잡힌다. 응답 본문도 비어 있다(observed, 같은 파일).
```text
--- token-boundary 응답 본문 ---
```
본문이 없다는 것은 오류 페이지조차 못 만들었다는 뜻이다. 고치려면 클라이언트에 타임아웃을 건다. Lettuce 의 연결·명령 타임아웃을 짧게 잡으면 `000` 이 `500` 이 되고, `500` 이 `000` 보다 낫다 — 적어도 말은 하기 때문이다.
**그런데 파드는 `Ready` 를 유지한다.** 이 절차의 가장 중요한 발견이다.
```bash label="[kc-lab-1] health 그룹 셋을 코드와 본문으로 본다"
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
```
실측은 이렇다(observed, `03-health-groups.txt`).
```text
=== 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` 쪽 측정에서 나왔다. 빈 값이나 이상한 값을 측정 결과로 읽지 않는다 — 그건 측정 실패다. A-1 에서도 빈 문자열을 변화로 읽어 판정이 틀어진 적이 있다. 이상하면 그 칸을 다시 친다.
```text
/actuator/health redis: DOWN → 전체 DOWN → 503
/actuator/health/readiness readinessState 만 → UP → kubelet: "정상"
```
그래서 Service 에서 파드를 빼지 않는다.
```bash label="[kc-lab-1] 엔드포인트가 아직 ready 인지 본다"
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
```
```text
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
ready: [10.42.0.52 10.42.1.124]
```
두 주소가 그대로 ready 다(observed). 주입 전에 본 그 두 IP 이고, 두 파드가 계속 트래픽을 받으며 계속 실패한다. 어느 replica 로 가도 결과가 같으므로 재시도해도 소용없다. `kubectl get endpoints` 는 쓰지 않는다 — v1.33 부터 deprecated 라 경고가 뜨고, 해설 문서 5절의 재현 절차에는 옛 형태(`get endpoints bff`)가 실려 있다.
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 가 없으므로 또 죽는다 — 재시작해도 안 나아지는 문제에 재시작을 걸게 된다.
**첫째 주입을 되돌리고 손대지 않는다.** BFF 를 재시작하고 싶은 충동을 참는다 — 재시작하면 스스로 회복하는가를 영영 알 수 없다.
```bash label="[kc-lab-1] ① Redis 를 다시 올린다"
date '+%H:%M:%S 복구'
kubectl -n keycloak-lab scale deployment/redis --replicas=1
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
①의 실측은 이렇다(observed, `04-persistence.txt`).
```text
=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out
```
```bash label="[kc-lab-1] ② 회복했는지와 재시작 횟수를 본다"
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
kubectl -n keycloak-lab get pods -l app=bff
```
실측은 이렇다(observed, `04-persistence.txt`).
```text
/actuator/health HTTP 200
/bff/token-boundary HTTP 302
BFF 재시작 필요했나: 0,0 회 재시작
```
`재시작 0,0` 이다. Lettuce 가 스스로 재연결했다. A-2 에서 Keycloak 의 커넥션 풀이 그랬던 것과 같고, liveness 를 Redis 에 걸었다면 파드가 재시작됐을 것이며 회복이 더 늦어졌을 것이다. `302` 는 실패가 아니다 — 세션이 사라졌으므로 로그인으로 보내는 것이고, Redis 가 비었으니 사용자는 로그아웃된다. 여기서 다음 질문이 나온다 — Redis 를 다시 띄웠는데 왜 세션이 없나. 답은 영속화가 없었으니까다. 그럼 켜면 되나.
**둘째 주입의 결과가 그 답이다.** 볼륨 없이 AOF 만 켠 채 파드를 지운다.
`rollout status` 가 돌아와도 지운 파드가 아직 종료 중일 수 있다. 이어지는 `exec deploy/redis` 가 그 파드에 붙으면 명령이 실패하거나 지우기 전 숫자를 낸다. 이 절차의 판정이 바로 그 `dbsize` 이므로, 숫자가 이상하면 `get pods -l app=redis` 로 `Running` 하나만 남았는지 보고 다시 친다.
```bash label="[kc-lab-1] ③ 볼륨 없이 파드를 지우고 남은 것을 센다"
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:aof
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
```
```text
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 0
b5:probe
b5:aof
appendonly no
```
두 가지가 같이 사라졌다(observed, 같은 파일).
| 사라진 것 | 왜 |
|---|---|
| **데이터** | `/data` 가 컨테이너 파일시스템이었다 — 컨테이너와 함께 없어졌다 |
| **설정** | `CONFIG SET` 은 **런타임 전용**이다. 재기동하면 매니페스트의 `args` 가 이긴다 |
쿠버네티스에서 영속화 설정만 켜는 것은 장식이다. `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — 파일은 만들어지고 로그도 정상이며, 사라지는 것은 재시작 순간뿐이다. 그리고 재시작은 노드 정비·이미지 갱신·OOM(out of memory, 메모리가 모자라 커널이 프로세스를 죽이는 일) 어느 것으로든 일어난다. 설정이 되돌아간 것도 따로 중요하다. `CONFIG SET` 으로 고친 값은 `CONFIG REWRITE` 를 하지 않으면 파일에 안 남고, 컨테이너에서는 그 파일 자체가 안 남는다. 런타임 설정으로 영속 동작을 정하려는 시도는 두 겹으로 실패한다.
볼륨을 되돌리고 같은 시험을 다시 하면 결과가 갈린다.
```bash label="[kc-lab-1] ④ 볼륨을 되돌리고 같은 시험을 다시 한다"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:pvc "written-on-pvc"
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:pvc
```
```text
=== 영속 볼륨 위에서 다시 시험 ===
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` 로 살아남았다(observed, 같은 파일). 중간의 `sed: -e expression #1, char 8: unknown option to 's'` 는 원래 실행의 스크립트가 낸 오류이고 측정과는 무관하다. 값에 `/` 가 들어간 문자열을 `sed 's/.../.../'` 에 그대로 넣으면 이렇게 된다. 증거 파일에서 그 오류를 지우지 않은 것은 그것이 「이 줄은 스크립트가 만든 것」이라는 표시이기 때문이다.
| 구성 | 파드 삭제 후 |
|---|---|
| AOF **끔**, 볼륨 없음 | 전부 소실 |
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
| AOF **켬**, **PVC** | **생존** |
볼륨이 먼저고 설정이 나중이다. 순서를 바꾸면 두 번째 줄이 되고, 두 번째 줄은 첫 번째 줄과 결과가 같은데 안심하고 있다는 점에서 더 나쁘다.
`appendfsync` 는 그래도 맞바꿈이다. 기본값은 `appendfsync everysec` 이다.
| 설정 | 잃는 양 | 비용 |
|---|---|---|
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
| **`everysec`** | **최대 1초** | 기본값 |
| `no` | OS 에 맡김 | 가장 빠름 |
세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가 다시 로그인해야 한다는 뜻이다. A-3 에서 본 PostgreSQL 의 `synchronous_commit OFF` 와 같은 모양의 맞바꿈이고, 거기서 Keycloak 이 같은 판단을 했다.
PVC 도 노드에 못박힌다.
```bash label="[kc-lab-1] PVC 의 스토리지 클래스를 본다"
kubectl get pvc -n keycloak-lab redis-data -o jsonpath='{.spec.storageClassName}'; echo
```
```text
local-path
```
`local-path` 는 노드의 디렉터리다. A-4 에서 본 것과 같다 — 노드가 죽으면 볼륨도 함께 접근 불가가 되고 파드는 다른 노드로 못 옮겨간다. 영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.
**이 실험은 관측에 숙제를 남겼다.** Grafana 에 이 실험의 그래프가 없는데, 안 찍은 것이 아니라 지표가 없다.
```text
=== B층 구성 요소의 지표가 있는가 ===
redis_up 시계열 0개
redis_connected_clients 시계열 0개
pg_up 시계열 0개
pg_stat_database_numbackends 시계열 0개
```
Prometheus 가 긁는 대상에 Redis·PostgreSQL·BFF 가 애초에 없다(observed, `04-observability-gap.txt`). 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 에서 이미 찾은 항목) |
## 복구와 원상복구 확인표
관찰 절이 이미 둘을 되돌렸다 — replica 를 1로 올렸고, `apply` 로 볼륨을 다시 붙였다. 이제 실험이 심은 키를 지우고 일곱 항목을 대조한다.
### 1. 매니페스트를 다시 적용해 볼륨과 설정을 되돌린다
**목적** — Deployment 를 저장소에 있는 모양으로 되돌린다.
① 관찰 절에서 이미 쳤더라도 한 번 더 친다. `apply` 는 같은 결과를 낸다.
```bash label="[kc-lab-1] 매니페스트를 다시 적용한다"
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
**예상 결과** — `deployment "redis" successfully rolled out` 이 나온다.
**왜 필요한가** — patch 로 뗀 `volumeMounts` 와 `volumes` 가 여기서 돌아온다. `CONFIG SET` 으로 켠 `appendonly` 는 이미 재기동에서 매니페스트의 `args` 에 졌으므로 따로 되돌릴 것이 없다.
**문제가 생기면** — PVC 가 `Pending` 이면 `describe pvc redis-data` 의 Events 를 본다.
### 2. 실험이 심은 키를 지운다
**목적** — `b5:` 로 시작하는 키 셋만 치운다.
① 접두어로만 지운다. **`FLUSHALL` 은 치지 않는다** — BFF 세션과 oauth2-proxy 세션이 같은 Redis 에 있다.
```bash label="[kc-lab-1] 실험 키만 지우고 남은 키를 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli del b5:aof b5:pvc b5:probe
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**예상 결과** — `--scan` 출력에 `b5:*` 가 없다.
**왜 필요한가** — `FLUSHALL` 을 치면 B-4 와 B-7 이 쓰는 `_oauth2_proxy-` 키까지 날아가고, 그 실험들이 뒤에 가서 갑자기 깨진다.
**문제가 생기면** — 지워지지 않으면 키 이름을 `--scan` 으로 먼저 눈으로 본다.
### 3. 일곱 항목을 대조한다
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| Redis | `kubectl -n keycloak-lab get deploy redis` | `1/1` |
| 볼륨 | `… get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}'` | `persistentVolumeClaim` 이 보인다 |
| PVC | `kubectl -n keycloak-lab get pvc` | `redis-data` `Bound` |
| 영속화 | `… exec deploy/redis -- redis-cli config get appendonly` | `yes` |
| BFF | `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` 을 붙인다 |
| `000` 을 서버 오류로 읽는다 | `000` 은 **응답을 못 받았다**는 curl 의 표기다 | `400`/`503` 과 구별한다 |
| **AOF 를 켰는데 안 남는다** | **`/data` 가 볼륨이 아니다** | 결론 내리기 전에 `spec.volumes` 를 먼저 |
| 파드를 지웠는데 데이터가 **살아남는다** | 볼륨 제거 패치가 안 먹었다 | `get pod … spec.volumes` 가 **비어야** 한다 |
| `config set` 한 값이 재기동 후 사라진다 | **런타임 전용이다.** 매니페스트 `args` 가 이긴다 | 재기동 후 `config get appendonly` |
| `/actuator/health` 응답 칸에 이상한 문자열 | **측정 실패다.** 값이 아니다 (`HTTP server`) | 그 칸을 다시 친다 |
| 파드가 `NotReady` 가 되기를 기다린다 | **안 된다.** `redis` 지표가 readiness 그룹에 없다 | health 그룹별 응답 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=bff` |
| 회복 후 로그인이 풀려 있다 | **정상이다.** Redis 가 비었으니 세션이 없다 | `302` 는 실패가 아니다 |
| BFF 를 재시작해 버렸다 | 「스스로 회복하는가」를 못 재게 된다 | 주입부터 다시. 손대지 않고 기다린다 |
| PVC 가 `Pending` | `local-path` 프로비저너가 없거나 노드가 안 맞는다 | `describe pvc redis-data` 의 Events |
| 다른 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:2414:28 KST` 에 돈 한 번의 실행에서 나왔다(observed).
- (observed) 주입 전 Redis 키 `1` 개 · PostgreSQL 토큰 `1` 행 · `save` 빈 값 · `appendonly no`, 정지 시각 `14:26:30`, 정지 후 세 경로의 `200` · `000` · `503` 과 빈 응답 본문, BFF 로그의 `pollConnect` · `finishConnect` 스택, 정지 중에도 `bff` 두 개가 `1/1` · `RESTARTS 0` 인 것, health 그룹의 `readiness 200` 과 `{"status":"UP"}`, 엔드포인트에 남은 `10.42.0.52` · `10.42.1.124`, 복구 후 `HTTP 200` · `HTTP 302` 와 `0,0 회 재시작`, `appendonlydir` 이 만들어진 `ls -la /data` 출력, 볼륨 없이 파드를 지운 뒤의 `dbsize: 0` · `appendonly no`, PVC 위에서 지운 뒤의 `dbsize: 1` · `written-on-pvc`, 후속 조사의 네 지표가 전부 시계열 0개인 것.
- **측정 실패를 값으로 읽지 않는다**(observed) — `03-health-groups.txt` 의 `/actuator/health HTTP server` 는 상태 코드가 아니고, 같은 파일의 `/actuator/health` 본문 칸도 비어 있다. `503` 은 `02-redis-down.txt` 쪽 측정에서 나온 값이다.
- (observed) `04-persistence.txt` 에 섞인 `sed: -e expression #1, char 8: unknown option to 's'` 는 원래 실행의 스크립트가 낸 오류이고 측정과 무관하다. 증거 파일에서 지우지 않았다.
- (unknown) 볼륨을 떼는 `patch deployment redis --type=json` 줄. **원래 실행은 반대 순서로 했다** — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 `curl` 에 로그인 쿠키가 없어 `/bff/token-boundary` 가 `3xx` 로 나오는 것도 가이드가 미검증으로 표시했다.
- 이 실험이 재지 않은 것 — Lettuce 타임아웃을 줄여 `000` 이 `500` 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. `readiness` 그룹에 `redis` 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다.
<!-- body:end -->
@@ -0,0 +1,588 @@
---
id: 7447ccbf-1800-43a4-a9d2-8ac774965c4b
kind: SETUP
slug: reproduce-b6-key-rotation
title: 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7447ccbf-1800-43a4-a9d2-8ac774965c4b/edit"
pinnedVersions:
- name: curl
version: 8.5.0
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다
realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공급자를 지운 뒤 두 토큰을 같은 두 줄로 다시 치는 절차다. 지운 키는 개인키와 함께 사라져 돌아오지 않으므로 실험대 전용 realm 에서만 한다. 약 15분.
## 관계
- **볼륨 없는 영속화와 유예 없는 키 회전**
이 절차가 만드는 `옛 401 · 새 200` 을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
여기서 `-q` 필터가 오류도 종료코드도 없이 빈 결과를 주는 대목이 그 아홉 건 중 하나다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
이 절차는 주입 검증에서 `old 200` 을 보기 전에는 관찰로 넘어가지 않는다. 그것을 안 보고 지우면 뒤에 나온 401 의 원인을 못 가른다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
같은 회전을 식별자가 없는 쪽에서 치는 편이다. 여기서 `kid` 가 겹침을 가능하게 하는 것을 보고 나면 그쪽에서 겹침이 왜 불가능한지가 한 줄로 끝난다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 친다. Keycloak 이미지에는 `curl``wget` 도 없어서(`exit 127`) 파드 안에서 HTTP 요청을 보낼 수 없다. JWKS(JSON Web Key Set, 서버가 공개키를 싣는 목록)와 토큰은 호스트에서 공개 이름으로 치고, `kcadm.sh``kubectl exec` 로 감싸 파드 안에서 돌린다.
터미널은 하나면 된다. 붙잡아 두어야 하는 셸이 없고, 대신 `OLD``NEW` 두 변수를 끝까지 들고 가므로 중간에 터미널을 닫지 않는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 리소스 서버는 `header-lab` |
| realm | `keycloak-patterns` — 클라이언트 `bff-confidential`, 사용자 `labuser` |
| 주입 수단 | `kcadm.sh create components``priority` 가 더 높은 RSA 공급자를 하나 더 만든다 |
| 판정하는 쪽 | 리소스 서버 `echo`. 이 절차의 401 과 200 은 전부 그 앱이 낸다 |
| 시간 제약 | access token 수명 60초. 토큰을 받고 1분 안에 그 토큰으로 친다 |
| 전 구간 | 약 15분. 주입 검증까지는 아무것도 안 깨진다 |
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `tr``grep` 으로 자른다 |
## 이 실험이 가르는 것
암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다.
| 어느 키인가 | 지금 상태 |
|---|---|
| 토큰 **저장소**의 암호화 키 | 존재하지 않는다. B-2 에서 `bytea` 안이 JWT 문자열 그대로였다 |
| 토큰 **서명** 키 (Keycloak realm) | 존재하고 회전할 수 있다 — 이 절차가 잰다 |
앞의 것이 없으므로 교체할 것도 없다. 그래서 이 절차는 뒤의 것만 치고, 거기서 본 모양이 나중에 앞의 것을 설계할 때 쓰인다.
원래 실행은 예측이 빗나간 실험이었다. 리소스 서버가 JWKS 를 캐시하니 옛 키를 지워도 한동안은 통할 것이라고 적어 두었는데, 제거 직후 바로 401 이 나왔다. 「교체」라는 한 단어가 성질이 정반대인 두 조작을 가리킨다.
```text
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
```
절차를 끝까지 밟으면 RS256 키 수가 `1 → 2 → 1` 로 움직이는 것, 겹치는 구간에서 옛 토큰과 새 토큰이 둘 다 200 인 것, 제거 뒤에 옛 토큰만 401 이 되는 것, 리소스 서버를 재시작해 캐시를 비워도 그 401 이 그대로인 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `bff-confidential` 과 사용자 `labuser` 가 있다.
- B-0 이 끝나 BFF 가 떠 있다.
- 리소스 서버(`echo`, 네임스페이스 `header-lab`)가 떠 있다. 이 절차의 401 과 200 은 전부 그 앱이 판정한다.
**이건 되돌릴 수 없는 실험이다.** 지우는 것은 서명 키 공급자이고 그 안의 개인키가 함께 사라진다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 `kid` 가 달라지므로, 옛 키로 서명된 토큰은 영구히 검증되지 않는다. 실험대에서만 한다.
되돌릴 수 있는 것은 주입 하나다. 방금 만든 공급자를 지우면 원래대로 돌아간다. id 는 주입이 화면에 찍어 주는 값이고 그 줄을 그대로 옮겨 친다 — 원래 실행에서는 `7902af43-a0cc-4ebd-ad25-04d563854d16` 이었다. 아래 블록에 박힌 값이 그 원래 실행의 id 라, 8 절을 친 뒤에 그 출력이 찍어 준 자기 id 로 바꿔야 지워진다. 8 절을 치기 전에는 지울 공급자가 없다.
```bash label="[kc-lab-1] 관찰 절로 넘어가기 전에 그만둘 때 — id 를 8 절 출력의 자기 값으로 바꾼다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns
```
## 주입 전에 같은 명령으로 먼저 본다
제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
```text
kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가
```
### 1. kcadm 세션을 파드 안에 만든다
**목적** — 뒤의 모든 `kcadm.sh` 명령이 관리 API 로 인증되게 한다.
**행동** — 관리자 자격증명으로 로그인하고, 값이 넘어갔는지는 길이로만 본다.
```bash label="[kc-lab-1] ① kcadm 에 로그인한다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
**예상 결과** — ① 은 아무것도 안 나오면 성공이다. 실패하면 한 줄짜리 오류가 뜬다. ② 는 0 이 아닌 수를 낸다.
**왜 필요한가** — `kcadm.sh` 는 파드 안 파일에 세션을 저장한다. 파드가 재시작되면 그 파일이 사라지고 그다음 모든 명령이 `401` 로 떨어지므로 맨 앞에서 한 번 해 둔다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다.
**문제가 생기면** — 중간에 `kcadm` 이 전부 `Unauthorized` 로 바뀌면 파드가 재시작된 것이다. ① 을 다시 친다.
### 2. 키 공급자 목록을 통째로 받는다
**무엇을 보는가** — 이 realm 에 어떤 키 공급자가 있고 각각의 id 가 무엇인지.
```bash label="[kc-lab-1] 공급자 목록을 필드 셋으로 받는다"
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"` 를 지금 적어 둔다. 관찰 절에서 지울 대상이다.
**이 값이 뜻하는 것** — 여기서 「키 공급자만 걸러 보자」는 시도가 빈 결과를 준다. 가이드가 이 줄을 미검증으로 표시했다(unknown) — 원래 실행에서 이렇게 치고 아무것도 못 받았다.
```bash label="[kc-lab-1] 이렇게 치면 조용히 빈 결과다 (unknown)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
```
오류도 종료코드도 없이 비어 있다. 「키 공급자가 하나도 없구나」로 읽으면 이 절차 전체가 무너진다. 빈 출력은 「없다」가 아니라 「이 명령으로는 안 보인다」일 수 있고, `--fields` 로 전체를 받아 눈으로 고른다.
### 3. JWKS 원문을 한 번 통째로 본다
**무엇을 보는가** — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.
```bash label="[kc-lab-1] ① JWKS 를 자르지 않고 본다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, `01-before-rotation.txt`).
```text
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
```
그 뒤로 `kty` · `alg` · `use` · `n` · `e` 가 이어지고 다음 키가 온다. `kid` 마다 `alg` 가 따로 붙는다. 읽을 만하게 자를 때는 `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
```bash label="[kc-lab-1] ② kid 만 뽑아 본다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
```text
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
```
**이 값이 뜻하는 것** — `kid` 는 둘인데 같은 파일의 윗줄은 RS256 키가 하나라고 적는다(observed).
```text
JWKS 의 RS256 키 수: 1
```
세는 단위가 다르다. JWKS 에는 서명 키만 실리지 않는다. 이 realm 에서는 암호화용 키(`RSA-OAEP` 계열)가 함께 실려 있고 그것도 `kid` 를 갖는다. `grep kid | wc -l` 로 세면 서명 키 수를 과다 계산한다.
### 4. RS256 만 세는 두 형태를 알아 둔다
**무엇을 보는가** — 알고리즘까지 보고 세는 방법. 아래 두 줄은 가이드가 미검증으로 표시했다(unknown).
JWKS 는 키 하나가 `}` 로 끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다.
```bash label="[kc-lab-1] ① 키 단위로 잘라 RS256 만 센다 (unknown)"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr '}' '\n' | grep -c RS256
```
Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.
```bash label="[kc-lab-1] ② Keycloak 에 직접 묻는다 (unknown)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get keys -r keycloak-patterns
```
**어디를 보나** — 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 키다.
**이 값이 뜻하는 것** — 이 두 명령은 원래 실행 기록에 출력이 없다. 위에 인용한 「RS256 키 수: 1」만이 실측이다.
### 5. 시험체가 될 옛 토큰을 하나 받아 둔다
**목적** — 회전 전에 발급된 토큰을 확보한다. 이 토큰 하나가 이 절차의 시험체다.
**행동** — 토큰 엔드포인트와 클라이언트 비밀을 변수에 담고 direct grant 로 받는다.
```bash label="[kc-lab-1] ① 옛 키로 서명된 토큰을 받고 길이만 본다"
KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(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}자"
```
**예상 결과** — 길이만 나온다. 원래 실행의 모양은 이렇다(observed).
```text
2043자
```
**왜 필요한가** — 변수 이름이 `OLD` 인 까닭은 회전이 끝난 뒤에도 이 값이 「옛 키로 서명된 토큰」으로 남아 있어야 하기 때문이다. 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다. 토큰 값도 클라이언트 비밀도 화면에 찍지 않고 길이만 본다.
**문제가 생기면** — `0자` 가 나오면 토큰을 못 받았다. 변수에 담지 말고 같은 `curl` 을 그대로 쳐서 응답 본문을 읽는다.
### 6. 그 토큰이 어느 키로 서명됐는지 읽는다
**무엇을 보는가** — JWT 의 첫 토막이 헤더이고 거기 `kid` 가 있다.
```bash label="[kc-lab-1] 토큰 헤더를 디코드한다"
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**어디를 보나** — 원래 실행의 모양은 이렇다(observed).
```json
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
```
증거 파일에는 이렇게 남아 있다(observed, `01-before-rotation.txt`).
```text
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
```
**이 값이 뜻하는 것** — `kid` 는 key ID 이고, 서명한 쪽이 어느 키를 썼는지 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 `kid` 를 찾아 공개키를 얻는다. `kid` 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고, 키가 바뀌는 순간 옛 토큰이 전부 죽는다. 겹치는 구간을 가능하게 하는 것이 이 `kid` 다. 여기서 본 값이 3 절의 목록에 있는지 대조한다. base64 패딩 때문에 끝이 깨져 보일 수 있고(`2>/dev/null` 이 그 불평을 지운다) 헤더는 짧아서 대개 온전히 보인다.
### 7. 그 토큰이 지금 통하는지 본다
**무엇을 보는가** — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.
```bash label="[kc-lab-1] ① 상태줄과 본문을 함께 본다"
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
200 이면 `subject` 같은 클레임이 돌아오고, 401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.
```bash label="[kc-lab-1] ② 상태 코드만 뽑는다"
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
```text
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
/api/me HTTP 200
```
**이 값이 뜻하는 것** — 회전 전에는 통한다. 원래 실행은 클러스터 안에서 `http://echo.header-lab.svc:8081/api/me` 를 쳤고, 이 절차가 공개 이름을 쓰는 까닭은 `kc-lab-1` 에서 클러스터 DNS 가 안 풀리기 때문이다. `app1.hyeonworks.com` 의 `/api` 는 Ingress 가 같은 `echo` 로 보내므로 도달하는 앱은 같다. 그리고 access token 은 60초짜리다(이 realm 은 `accessTokenLifespan=60`). 1분을 넘기면 회전과 무관하게 401 이 나온다.
## 주입
### 8. 우선순위가 더 높은 RSA 공급자를 추가한다
**목적** — 발급은 새 키로 가고 검증은 옛 키와 새 키를 둘 다 받는 상태를 만든다.
Keycloak 의 키 회전은 바꾸기가 아니라 더 높은 우선순위로 추가하기다. 기존 공급자는 그대로 두고 `priority` 가 더 큰 공급자를 하나 더 만든다.
```text
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
```
**행동** — 공급자를 만들고 시각을 남긴다.
```bash label="[kc-lab-1] priority 200 짜리 RSA 공급자를 만든다"
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 추가'
```
**예상 결과** — 실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
```
돌아온 id 를 적어 둔다. 전제 절의 되돌리기가 그 값을 쓴다.
**왜 필요한가** — `config.priority` 가 기존 공급자보다 커야 발급이 새 키로 간다. 기본값은 100 이고 여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 발급에 쓰이지 않아 주입 검증에서 `kid` 가 안 바뀐다. `config.*` 값이 대괄호로 감싼 배열인 것에도 주의한다 — `-s config.priority=200` 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.
**문제가 생기면** — 생성이 거절되면 `config.*` 의 대괄호부터 본다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 주입 전과 똑같은 명령을 다시 친다.
### 9. JWKS 에 옛 키가 남아 있는가
```bash label="[kc-lab-1] 3 절과 똑같은 줄을 다시 친다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
```
옛 `kid`(`OY-caYDN…`)가 목록에서 빠지지 않았다. 새 것이 하나 늘었고 아무것도 사라지지 않았다. JWKS 는 지금 검증에 쓸 수 있는 키 전부를 싣는 목록이고, 추가는 그 목록을 늘린다.
### 10. 새 토큰은 어느 키로 서명되는가
`OLD` 은 건드리지 않는다.
```bash label="[kc-lab-1] 새 토큰을 받고 헤더를 읽는다"
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
```
실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
```
`kid` 가 우선순위 200 짜리 새 키로 바뀌었다. 발급은 우선순위가 가장 높은 키로 간다. 여기서 `kid` 가 안 바뀌었다면 `priority` 를 낮게 준 것이다.
### 11. 둘 다 통해야 겹치는 구간이 무중단이다
```bash label="[kc-lab-1] 두 토큰을 같은 두 줄로 친다"
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
```
실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
```
둘 다 200 이므로 키 추가는 무중단이다. 새 토큰은 새 키로 서명되고 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증되며 사용자는 아무것도 못 느낀다.
여기서 `old` 가 401 이면 둘 중 하나다. 토큰이 만료됐거나(60초), 추가 말고 다른 것을 건드렸다. 가르는 법은 옛 토큰의 `exp` 를 보는 것이고 JWT 의 가운데 토막이 클레임이다.
```bash label="[kc-lab-1] 만료인지 아닌지 가른다"
echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
date +%s
```
`exp` 가 지금보다 작으면 만료다. 다시 받아야 하는데, 순서 그대로 5 절만 다시 치면 8 절이 만든 `rsa-rotated` 가 이미 `priority` 200 이라 새로 받은 토큰도 새 키로 서명된다. 8 절이 화면에 찍어 준 id 로 그 공급자부터 지우고(전제 절의 되돌리기 한 줄), 5 절로 토큰을 받고, 8 절을 다시 쳐서 추가한 뒤 11 절로 온다.
## 관찰
**여기서부터 되돌릴 수 없다.** 계속하기 전에 셋을 확인한다 — 이 realm 이 실험대 전용인가, 지금 살아 있는 세션 중에 잃으면 곤란한 것이 있는가, 11 절의 `old 200` 을 실제로 봤는가. 마지막 것을 안 봤다면 401 이 나와도 원인을 못 가른다.
**넷째로 `$OLD` 에 시간이 얼마나 남았는지 본다.** access token 이 60초짜리라, 12 절에서 목록을 받아 눈으로 id 를 고르고 13 절에서 그 id 를 손으로 옮겨 적는 동안 만료된다. 그러면 15 절의 `old 401` 이 키 제거 때문인지 만료 때문인지 안 갈린다 — 이 절차의 결론이 바로 그 401 이라, 여기서 못 가르면 아무것도 못 잰다. 11 절의 만료 확인 두 줄을 지금 한 번 쳐서 `exp` 와 `date +%s` 의 차이를 보고, 12·13 절을 칠 만큼 안 남았으면 11 절이 안내한 대로 `$OLD` 를 다시 받고 온다.
### 12. 지울 대상을 정확히 고른다
**무엇을 보는가** — 남길 것과 지울 것의 id. `-q` 는 여전히 안 먹는다.
```bash label="[kc-lab-1] ① 목록을 다시 받는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId
```
목록이 길면 그 항목 둘레만 잘라 본다. `"id"` 는 `"name"` 보다 위에 나온다.
```bash label="[kc-lab-1] ② 지울 항목 둘레만 본다"
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"'
```
**어디를 보나** — 남길 것이 `"name" : "rsa-rotated"` 이고 지울 것이 `"name" : "rsa-generated"` 다.
**이 값이 뜻하는 것** — 둘을 바꿔 지우면 실험이 뒤집힌다. 원래 실행에서 옛 공급자의 id 는 `980ee9b7…` 로 시작했고 그것이 `OY-caYDN…` 키를 갖고 있었다. 환경마다 id 가 다르고 증거에 남은 것도 앞 8자뿐이니 전체 id 는 위 출력에서 그대로 옮겨 온다.
### 13. 옛 공급자를 지운다
**목적** — 옛 서명 키를 JWKS 에서 없앤다.
**행동** — 위 출력의 id 를 변수에 옮기고 지운다. 아래 블록은 그대로 붙여넣으면 안 된다. 첫 줄의 `980ee9b7-...` 은 원래 실행의 값이므로 12 절 출력에서 읽은 자기 id 로 바꾼다. 안 바꾸고 치면 없는 컴포넌트를 지우라는 요청이 되어 옛 공급자는 살아 있고, 15 절이 `옛 200` 을 내 결론이 뒤집힌다.
```bash label="[kc-lab-1] 옛 공급자를 지우고 시각을 남긴다"
OLDID=980ee9b7-... # ← 위 출력에서 그대로 옮긴다. 환경마다 다르다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/"$OLDID" -r keycloak-patterns
date '+%H:%M:%S 제거'
```
**예상 결과** — 조용히 끝나면 성공이다. 증거 파일에는 이렇게 남아 있다(observed, `03-old-key-removed.txt`).
```text
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
```
**왜 필요한가** — 시각을 적어 두면 뒤에 나온 401 을 이 조작에 귀속할 수 있다. 그리고 지워진 것은 공급자이므로 그 안의 개인키도 함께 사라진다.
**문제가 생기면** — 지운 뒤 새 토큰까지 401 이면 새 공급자를 지운 것이다. 14 절과 15 절을 먼저 치고 `kid` 를 대조한다.
### 14. JWKS 에서 사라졌는지 본다
```bash label="[kc-lab-1] 또 같은 줄을 친다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
실측은 이렇다(observed, `03-old-key-removed.txt`).
```text
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
```
`OY-caYDN…` 이 없고 RS256 은 다시 하나다. `gokjn0…` 은 처음부터 끝까지 목록에 있는데, 서명 키가 아니라 암호화 키이기 때문이다.
### 15. 두 토큰을 같은 두 줄로 다시 친다
**치기 전에 `$OLD` 가 아직 안 죽었는지 본다.** 11 절의 만료 확인 두 줄을 그대로 다시 친다. `exp` 가 `date +%s` 보다 작으면 아래에서 나올 `old 401` 은 키 제거가 아니라 만료이고, 두 401 은 화면에서 똑같이 보인다.
```bash label="[kc-lab-1] 11 절과 똑같은 두 줄"
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
```
실측은 이렇다(observed, `03-old-key-removed.txt`).
```text
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
```
제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.
**`$OLD` 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다.** 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown).
### 16. 리소스 서버를 재시작해 캐시를 비운다
**목적** — 401 이 캐시 상태 때문인지 가른다.
```bash label="[kc-lab-1] echo 를 다시 띄우고 기다린다"
kubectl -n header-lab rollout restart deploy/echo
kubectl -n header-lab rollout status deploy/echo --timeout=180s
```
그다음 15 절의 두 줄을 다시 친다. 실측은 이렇다(observed, `03-old-key-removed.txt`).
```text
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
```
**왜 필요한가** — 재시작 전과 후가 같으므로 401 은 캐시 상태와 무관하고 캐시는 유예를 주지 않았다. Spring 의 `NimbusJwtDecoder` 는 모르는 `kid` 를 만나면 JWKS 를 다시 가져온다. 캐시는 이미 아는 키를 다시 안 받으려는 장치이지 옛 키를 붙잡아 두는 장치가 아니다.
```text
옛 토큰 도착
├─▶ kid = OY-caYDN… → 캐시에 없다
│ │
│ └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
└─▶ 새로 받은 JWKS 에도 없다 → 401
```
캐시가 오히려 제거를 빨리 반영시킨다. 유예는 캐시로 만드는 것이 아니라 옛 키를 JWKS 에 남겨 두는 기간으로 만든다.
**문제가 생기면** — 재시작 뒤 옛 토큰이 200 이면 지운 것이 그 토큰의 키가 아니다. 12 절의 목록과 6 절의 `kid` 를 대조한다.
### 겹치는 구간은 얼마나 길어야 하나
**이 절차는 그 길이를 재지 않았다.** 추가와 제거가 연달아 일어났고 전 구간이 약 15분이다. 아래 값은 realm 설정에서 따라 나온 추론이다.
| 이 실험대에서 | 수명 |
|---|---|
| access token | 60초 |
| refresh token | 1800초 (30분) |
| 필요한 겹침 | 최소 30분 — 앞 두 값에서 따라 나온 추론이고 측정하지 않았다 |
겹침의 최소 길이는 옛 키로 서명된 것 중 가장 오래 사는 것의 수명과 같다. 실제로 재려면 추가와 제거 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로 제거 뒤에 갱신을 시도한다. 이 절차에는 그 단계가 없다.
수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
```bash label="[kc-lab-1] realm 의 수명 세 값을 받는다 (unknown)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
```
겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
```text
쓰기: 새 key 하나로만
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
```
저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다. 그것이 없을 때 무슨 일이 나는지는 B-7 이 잰다.
## 복구와 원상복구 확인표
**이 절차에는 원상복구가 없다.** 지운 키 공급자는 개인키와 함께 사라졌고, 같은 이름으로 다시 만들면 새 키 쌍이 생기고 `kid` 가 다르므로 옛 토큰은 그래도 401 이다. 정상 상태는 새 키 하나만 남은 상태이고, 실험 전과 다르지만 깨진 상태가 아니다.
| 남은 것 | 어떻게 | 왜 |
|---|---|---|
| `rsa-rotated` 공급자 | 그냥 둔다. 지금 유일한 RS256 서명 키다 | 지우면 realm 이 서명할 키를 잃는다 |
| 셸 변수 `OLD` `NEW` `CS` | 터미널을 닫으면 사라진다 | `unset OLD NEW CS` |
| 실험 중 발급한 토큰 | 60초 뒤 만료된다 | 별도 조치 없음 |
이름이 거슬리면 새 공급자를 하나 더 만들고 `rsa-rotated` 를 지운다. 다만 그것 역시 또 한 번의 회전이고 또 하나의 새 키다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 서명 키 | `curl -s …/protocol/openid-connect/certs \| tr ',' '\n' \| grep kid` | RS256 이 하나 |
| 새 토큰 | 토큰 발급 + `/api/me` | `200` |
| 리소스 서버 | `kubectl -n header-lab get pods` | `echo` 가 `1/1 Running` |
| Keycloak | `kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
| 공급자 목록 | `kcadm get components --fields id,name,providerId` | `rsa-generated` 가 없고 `rsa-rotated` 가 있다 |
## 막히면
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `kcadm get components -q type=...` 가 빈 결과 | `-q` 필터가 안 먹는다. 오류도 없다 | `--fields id,name,providerId` 로 전체를 받는다 |
| `kcadm` 이 전부 `401`/`Unauthorized` | 파드가 재시작되어 kcadm 세션이 사라졌다 | `config credentials` 를 다시 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | JWKS 와 토큰은 `kc-lab-1` 호스트에서 친다 |
| `jq: command not found` | 이 실험대에는 jq 가 없다 | `tr ',' '\n' \| grep` 로 자른다 |
| kid 를 세니 2개인데 문서는 1개라고 한다 | RS256 이 아닌 암호화 키가 섞여 있다 | `tr '}' '\n' \| grep -c RS256` 또는 `kcadm get keys` |
| 공급자를 추가했는데 새 토큰의 kid 가 그대로 | `config.priority` 가 기존보다 낮다 | 값이 `["200"]` 처럼 배열인지 |
| 추가만 했는데 옛 토큰이 401 | 추가가 아니라 토큰이 만료됐다(60초) | 클레임의 `exp` 와 `date +%s` 비교 |
| 제거했는데 새 토큰이 401 | 지운 것이 새 공급자다 | `kid` 를 다시 확인하고 남은 공급자 목록을 본다 |
| 제거했는데 옛 토큰이 200 | 지운 것이 그 토큰의 키가 아니다 | 토큰 헤더의 `kid` 와 지운 공급자의 키를 대조 |
| 「캐시 때문일 것」이라 재시작을 기다린다 | 캐시는 유예를 주지 않는다 | 재시작 전후가 같다 |
| 지운 키를 되살리려 한다 | 되살릴 수 없다. 같은 이름과 같은 키는 다르다 | 새 키 하나만 남은 상태가 정상이다 |
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:3014:32 KST` 에 돈 한 번의 실행에서 나왔다(observed). 해설 문서 머리의 `15:5016:00 KST` 는 문서를 쓴 시각이고 증거 파일의 mtime 이 앞의 값이라, 실측으로 인용하는 것은 뒤쪽이라고 가이드가 적는다.
- (observed) 회전 전 JWKS 의 `kid` 두 개(`gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4` · `OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM`)와 RS256 키 수 1, 발급 토큰의 `kid` 가 `OY-caYDN…` 인 것, 그 토큰의 `/api/me HTTP 200`, 추가한 공급자의 id `7902af43-a0cc-4ebd-ad25-04d563854d16`, 회전 후 RS256 키 수 2 와 늘어난 `kid` `1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84`, 새 토큰의 `kid` 가 그것인 것, 겹치는 구간의 `옛 200 · 새 200`, 제거 후 RS256 키 수 1 과 `옛 401 · 새 200`, `echo` 를 재시작한 뒤에도 `옛 401 · 새 200` 인 것.
- 비밀은 길이와 존재만 적었다. 클라이언트 비밀은 `CS` 변수에 명령 치환으로만 넘겨 화면에 찍지 않고, admin 비밀번호도 `wc -c` 로 길이만 본다. 토큰은 `2043자` 라는 길이만 옮겼고 값은 증거 파일에 있다. `kid` 와 공급자 id 는 공개 식별자라 그대로 적었다.
- (unknown) `-q type=org.keycloak.keys.KeyProvider` 로 거르는 줄(조용히 빈 결과를 준다), `tr '}' '\n' | grep -c RS256` 로 RS256 만 세는 줄, `kcadm get keys` 로 알고리즘과 상태를 보는 줄, realm 의 수명 세 값을 한 번에 받는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다.
- 판 번호는 이 편의 출력에 하나도 안 찍혔다. B층 아홉 편 가운데 판 번호가 남은 것은 다섯 편이고 B-6 은 거기 없다(observed). `curl 8.5.0` 은 같은 실험대의 B-4 가 echo 앱에서 되돌려받은 user-agent 이지 이 편이 잰 값이 아니다(inferred).
- 추론이지 측정이 아닌 것 — 「겹침은 최소 30분」은 access token 60초와 refresh token 1800초라는 설정에서 따라 나온 값이다. 겹침을 실제로 30분 유지하며 그 사이에 발급된 refresh token 이 제거 뒤에 어떻게 되는지는 측정하지 않았다.
<!-- body:end -->