[Spring] Redis Session & Cache with k8s

민스킴·2024년 12월 10일
0

Spring

목록 보기
13/14

시작

Spring 애플리케이션들이 공통으로 사용할 Redis를 구축해야 하는 임무가 생겼다. 이를 고민하고 k8s 환경에 구축했던 과정을 기록 해보았다.

조건

k8s로 Spring Boot 파드를 여러 개 띄운 상황에서, Redis를 Session과 Cache로 사용해보자.
spring 애플리케이션들이 Redis를 세션 및 캐시 기능으로 공통 사용하기 때문에, 단일 장애 지점이 되지 않도록 고가용성을 보장해야 한다.


Spring Redis Session & Cache with k8s

Spring Redis를 사용하는 경우 Lettuce 라이브러리를 사용하게 됩니다.

(jedis 라이브러리는 성능적, 기능적으로 부족한 점이 있기에 사용하지 않습니다.)

Redis를 Session이나 Cache로 사용하기 위해서는 LettuceConnectionFactory 빈 등록이 필요합니다. 이 구현체를 사용해서 Redis와 연결을 맺기 때문입니다.

세션을 사용하기 위해서 SessionRepository 가 필요하고, Redis에서는 RedisSessionRepository 를 대신 사용합니다.

RedisSessionRepository 구현체인 ReactiveRedisSessionRepository 에서 ReactiveRedisOperations 구현체인 ReactiveRedisTemplate 를 사용합니다.
이때, ReactiveRedisTemplate 에서 LettuceConnectionFactory 을 사용하여 Redis와 연결을 맺습니다.

캐시의 경우도 비슷하게 RedisCacheManager 에서 LettuceConnectionFactory 를 사용합니다.

Session과 Cache 차이점

세션과 캐시 기능에는 큰 차이점이 존재합니다. 캐시의 경우 마스터 노드의 장애 발생 시에, failover와 무관하게 레플리카 노드에서 read 작업이 가능합니다.

하지만 세션의 경우 레플리카 노드에서 read 작업이 불가능 합니다.
RedisMessageListenerContainer 클래스를 사용한 pub/sub 형태로 데이터의 동기화가 이루어지기 때문입니다.
(해당 클래스에서 pub/sub 기능은 Master 노드에서만 이루어질 수 있기 때문에 Replica 노드에서 read 작업이 수행되지 않습니다.)

결론적으로 Redis에서 세션 기능을 사용하고 싶으면, StandAlone, Sentinel, Cluster 모드 전부 Master 노드에서만 read 작업이 가능합니다. 따라서, 고가용성을 보장하기 위해서는 Master 노드가 3개 이상인 Cluster 모드로 변경해야 합니다.
(Cluster 모드에서도 완전한 정상 작동을 보장하지 않습니다. 샤딩으로 데이터가 분산 저장되었기 때문에, 장애가 발생한 Master 노드가 아닌 다른 Master 노드에 데이터가 저장된 경우에만 정상 작동을 합니다.)

세션과 캐시 기능을 같이 사용하는 경우에, 마스터 노드 장애 발생한 경우 캐시 기능도 동작하지 않습니다.

Spring Boot 환경에서 사용자로부터 요청을 받을 때, JSESSIONID 쿠키를 통해 세션 ID를 받은 경우 Filter에서 세션을 확인합니다. 이때, 마스터 노드에 장애가 발생했기에 세션을 확인하지 못해 요청이 Filter에서 차단되어 캐시 기능도 작동하지 않습니다.

Redis 모드 선택 가이드

HA(고가용성)을 보장해야 한다는 전제 조건이 있다.

  1. 캐시 기능만 사용하는 경우 && On-Premise 환경 → Sentinel
    1. 마스터 노드의 장애가 발생 할 경우 FailOver 기능이 필요하기 때문에
  2. 캐시 기능만 사용하는 경우 && k8s 환경 → StandAlone or Sentinel
    1. k8s에서 마스터 노드가 있는 파드의 재실행을 보장해주기 때문에, FailOver 기능이 없는StandAlone을 사용해도 괜찮다.
  3. 세션 기능을 사용하는 경우 → Cluster
    1. StandAlone, Sentinel을 사용하면 마스터 노드의 장애가 복구되기 전까지 모든 작업을 할 수 없다.
      (마스터 노드만을 사용해서 세션 기능이 동작하기 때문이다.)
    2. Cluster 모드의 경우 마스터 노드의 수와 비례해서 장애 확률을 낮출 수 있다.
      1. Cluster 모드는 샤딩 방식으로 데이터를 마스터 노드에 저장한다. 마스터 노드가 3개 있고 1개의 마스터에서 장애가 발생하면, 기존 사용자는 장애가 발생하지 않은 2개 마스터 노드 중에 데이터가 저장 된 경우 문제없이 이용 가능하다.
        (신규 사용자의 경우 어느 마스터 노드에 데이터를 저장할지 예측할 수 없다. 해시 테이블을 통해서 결정되기 때문이다. 따라서 장애의 확률은 33% 이다.)

레디스 모드별 구조

StandAlone, Sentinel은 둘 다 마스터가 1개로 고정되어 있다.

Cluster는 full-Mesh 형태로 모든 노드가 서로 연결되어 있다.

k8s 환경에서 Redis Cluster 구현

k8s 환경에서 마스터 3, 슬레이브 3 구조의 클러스터 구축.

컨트롤 플레인을 제외한 워커 노드에 균등하게 배포할 수 있도록 설정.

redis-cm.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-config
data:
  redis.conf: |
    bind 0.0.0.0
    protected-mode no
    port 6379
    cluster-enabled yes
    cluster-config-file nodes.conf
    cluster-node-timeout 5000
    appendonly yes

redis-hs.yaml

apiVersion: v1
kind: Service
metadata:
  name: redis-svc-headless
  labels:
    app: redis-svc-headless
spec:
  clusterIP: None
  selector:
    app: redis
  ports:
  - port: 6379
    targetPort: 6379

redis-sts.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
spec:
  replicas: 6
  serviceName: redis-svc-headless
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:7.0
        ports:
        - containerPort: 6379
        volumeMounts:
        - name: redis-config-volume
          mountPath: /usr/local/etc/redis/redis.conf
          subPath: redis.conf
        command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
      volumes:
      - name: redis-config-volume
        configMap:
          name: redis-config
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: node-role.kubernetes.io/control-plane
                operator: DoesNotExist
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: redis

레디스 파드 중에 하나에 접속하고, 아래 명령어를 입력해서 클러스터를 직접 묶어줘야 한다.

redis-cli --cluster create redis-0.redis-svc-headless.redis.svc.cluster.local:6379 redis-1.redis-svc-headless.redis.svc.cluster.local:6379 redis-2.redis-svc-headless.redis.svc.cluster.local:6379 --cluster-replicas 0

redis-cli --cluster add-node redis-3.redis-svc-headless.redis.svc.cluster.local:6379 redis-0.redis-svc-headless.redis.svc.cluster.local:6379 --cluster-slave
redis-cli --cluster add-node redis-4.redis-svc-headless.redis.svc.cluster.local:6379 redis-1.redis-svc-headless.redis.svc.cluster.local:6379 --cluster-slave
redis-cli --cluster add-node redis-5.redis-svc-headless.redis.svc.cluster.local:6379 redis-2.redis-svc-headless.redis.svc.cluster.local:6379 --cluster-slave

아래는 Spring 에서 Redis 연결 관련 빈 생성 코드이다.

RedisConfig.java

@Configuration
@EnableCaching
@EnableSpringHttpSession
public class RedisConfig {

    @Bean
    public LettuceConnectionFactory cacheConnectionFactory() {

        ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
                .enableAllAdaptiveRefreshTriggers()
                .enablePeriodicRefresh(Duration.ofHours(1L))
                .build();

        SocketOptions socketOptions = SocketOptions.builder()
                .connectTimeout(Duration.ofSeconds(1))  // 연결 시도 타임아웃 설정
                .build();

        TimeoutOptions timeoutOptions = TimeoutOptions.builder()
                .fixedTimeout(Duration.ofSeconds(3))    // 모든 명령에 대해 고정된 타임아웃 적용
                .build();

        ClusterClientOptions clientOptions = ClusterClientOptions.builder()
                .topologyRefreshOptions(clusterTopologyRefreshOptions)
                .autoReconnect(true)             // 자동 재연결 설정
                .socketOptions(socketOptions)            // 소켓 옵션 설정 적용
                .timeoutOptions(timeoutOptions)          // 타임아웃 옵션 설정 적용
                .build();

//         Lettuce 클라이언트 설정
        LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
                .readFrom(ReadFrom.REPLICA_PREFERRED) // 슬레이브에서 읽기를 우선시
                .clientOptions(clientOptions)
                .build();

        RedisClusterConfiguration redisConfig = new RedisClusterConfiguration(
                Arrays.asList(
                        "redis-0.redis-svc-headless.redis.svc.cluster.local:6379",
                        "redis-1.redis-svc-headless.redis.svc.cluster.local:6379",
                        "redis-2.redis-svc-headless.redis.svc.cluster.local:6379",
                        "redis-3.redis-svc-headless.redis.svc.cluster.local:6379",
                        "redis-4.redis-svc-headless.redis.svc.cluster.local:6379",
                        "redis-5.redis-svc-headless.redis.svc.cluster.local:6379"
                )
        );

        return new LettuceConnectionFactory(redisConfig, clientConfig);
    }

    @Bean
    public RedisSerializer<Object> springRedisSerializer(ObjectMapper objectMapper) {
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }
}

정리

Spring에서 Redis를 세션 및 캐시 저장소로 많이들 사용합니다. 하지만 Redis를 세션 저장소로 사용할 때, 레플리카에서 읽기 작업을 수행할 수 없고, 마스터에서만 가능하다는 사실을 알려주는 곳은 많지 않은 것 같습니다.

일반적인 상황에서는 이를 고려하지 않기 때문이라고 생각합니다. 또한, 단순하게 마스터에 장애가 발생할 경우 레플리카에서 읽기 작업을 수행할 수 있다고 생각하기 쉽습니다. 레플리카가 존재하는 경우에는 마스터와 레플리카의 읽기, 쓰기 작업 분리가 가능하다고 생각하기 때문입니다.

하지만 Spring에서 Redis를 세션으로 사용하는 경우, Redis를 pub/sub 구조로 동기화 하면서 사용하기 때문에 마스터에서만 읽기 작업이 가능합니다.

고가용성을 보장해야하는 구조에서는 이를 고려해서 적절한 Redis 모드를 선택해야 합니다.

profile
Boys, be ambitious!

0개의 댓글