--- 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 캐시를 비운 뒤에도 한 번 더 보낸다. ## 본문 ## 영속화를 켜도 재기동 뒤에 아무것도 없었다 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 을 붙이고 키 하나를 심은 뒤 파드를 지웠더니 재기동 후에도 그 키가 남아 있었다는 기록이 한 번 있다. 다만 영속화가 어느 시점까지 복구하는지는 이 실험에서 재지 않았다. 키 회전 쪽은 옛 공급자를 남긴 경우와 제거한 경우 두 가지만 걸었다. 옛 키를 얼마나 오래 남겨 두어야 나간 토큰이 전부 만료되는지는 확인하지 않았다.