Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/case/case-persistence-without-a-volume-and-rotation-without-overlap.md
T
DongHyeonkaandClaude Opus 5 2109f726fe 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>
2026-09-17 11:01:55 +09:00

150 lines
11 KiB
Markdown

---
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 -->