기록 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>
150 lines
11 KiB
Markdown
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 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 -->
|