แชร์ประสบการณ์ Config ตัว Amazon ElastiCache ให้ใช้งานกับ Spring Boot

แชร์ประสบการณ์ Config ตัว Amazon ElastiCache ให้ใช้งานกับ Spring Boot

Story

2 - 3 วันที่ผ่านมาพยายามต่อ Redis บน ElastiCache แล้ว Error ตลอดเวลาเรียกใช้ จะมี Log ขึ้นประมาณข้างล่างนี้ แล้วแก้จนมันใช้ได้แล้วเอาขึ้น Server ไปละ นี่คือเรื่องของมัน

org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException: Unable to connect to ███████-cache.███████.com:6379

Caused by: io.lettuce.core.RedisConnectionException: Unable to connect to ███████-cache.███████.com:6379
e
Caused by: javax.net.ssl.SSLHandshakeException: General SSLEngine problem

Caused by: javax.net.ssl.SSLHandshakeException: General SSLEngine problem

Caused by: java.security.cert.CertificateException: No subject alternative DNS name matching ███████-cache.███████.com found.

สันนิษฐานแรกคือน่าจะเกี่ยวกับ In-Transit Encryption feature ของ ElastiCache's Redis ซึ่งถูกเลือกไว้แล้ว แล้วหลังจาก Research ไปซักระยะเลยเจอว่า ElastiCache's Redis allow ให้ Connect ผ่าน VPC เท่านั้น

Amazon ElastiCache Nodes, deployed within a VPC, can never be accessed from the Internet or from EC2 Instances outside the VPC. Source

ระหว่างนั้นเลยไปทดลองสร้างเครื่อง EC2 ในวง VPC เดียวกันมา Connect แต่ก็เจอปัญหา Error: Connection reset by peer เวลาต่อจาก redis-cli ที่วิ่งอยู่บน Stunnel แล้ว ซึ่งแก้เท่าไรก็ยังไม่ได้ และไม่ได้เกี่ยวกับ IAM Role ด้วยเพราะลอง FullAccessElastiCache ก็ยังไม่ได้

สุดท้ายพี่กานต์ลองใช้ Python Redis client ยิงดูปรากฏว่าติด หน้าตาโค้ดประมาณนี้ เลยรู้สึกว่าไม่ใช่ละ

import redis

r = redis.Redis(host='███████-cache.███████.com', port=6379, db=0, ssl=True)
r.set('foo', 'bar')
print(r.get('foo'))

ซึ่งมีจุดที่ remind อย่างนึงคือ ssl=True เลยพุ่งเป้ากลับมาที่เรื่อง SSL Certificate น่าจะมีปัญหาอะไรบางอย่าง หลังจากนั้นพยายามลองเซต spring.redis.ssl=true ใน application.properties ก็ยังไม่ได้ขึ้น Exception เหมือนตอนแรก ก็เลยมานั่งมอง Exception ละเอียดๆ อีกทีจนไปสะดุดตาอันสุดท้าย

Caused by: java.security.cert.CertificateException: No subject alternative DNS name matching ███████-cache.███████.com found.

Search ไปๆ มาๆ เลยมาเจอบล็อกนี้ [Solved] java.security.cert.CertificateException: No subject alternative names present ใจความหลักๆ เหมือนกันคือต่อ LDAP ไม่ได้ เพราะ Java 1.8.0_181 เปิด "Endpoint identification" เป็น default เพื่อให้ใช้กับ LDAPS connection ได้ ซึ่งก็มีวิธีปิดอยู่ง่ายๆ คือไปเพิ่ม property ข้างล่างก็ได้ละ

-Dcom.sun.jndi.ldap.object.disableEndpointIdentification=true

ซึ่งถึงแม้มันจะไม่ได้แก้ปัญหาของ ElastiCache ต่อไม่ได้ตรงๆ แต่ได้ keyword สำคัญมากคือ disableEndpointIdentification ลิ้งค์ต่อๆ มาเลยยังพุ่งเป้าไปที่เรื่องนี้อยู่ แล้วมาเจอกับ issues นี้ใน github CertificateException while connecting to Azure redis cluster. #1296 ซึ่งเจอกับ Redis เต็มๆ แต่เป็น Azure แต่ที่สำคัญคือ อันนี้เป็นปัญหาบน Library: Redisson ซึ่งเป็น Redis Java Client เหมือนกัน และก็ยิ่งย้ำความเชื่อว่ามันเป็นเรื่อง EndpointIdentification นี่แหละ

ณ จุดนี้คือกลับมานั่งทำความเข้าใจอีกทีว่า spring-data-redis ใช้อะไรอยู่ข้างล่าง จาก Official Document เลยค้นพบว่าข้างล่างมันใช้ Lettuce เป็น Client อยู่ เลยมาเจอ issue Lettuce can't connect to Redis Cluster + SSL but can connect to same Redis server + SSL if treated as Standalone node. #1454 ซึ่งคำตอบแรกของ issue นี้ คือคำตอบเลย

There are a couple of things you can do about:

  1. If the Redis IP addresses can be mapped onto a hostname, then please configure a MappingSocketAddressResolver through ClientResources to map IP addresses into hostnames that are listed in the SSL certificate
  2. Reach out to the Azure support team so that they fix the SSL certificates and include all cluster IP addresses
  3. (least favorable option) Disable SSL certificate validation. That can be done with RedisURI.setVerifyPeer(false) when using Lettuce directly. For Spring, you should be able to register a LettuceClientConfigurationBuilder customizer to invoke LettuceSslClientConfigurationBuilder.disablePeerVerification().

ซึ่งทำให้เราได้คีย์เวิร์คสำคัญมาคือ disablePeerVerification (มันต้องเหมือนข้างบนแน่ๆ) คำถามถัดมาคือ เราจะ disable มันยังไงใน Spring เลยต้องไปหาวิธีใช้ LettuceSslClientConfigurationBuilder อยู่ซักพักใหญ่ๆ เลย ซึ่งอ่าน Doc เฉยๆ ก็เขียนยังไม่เป็นด้วยประสบการณ์ Java แค่นี้

สุดท้ายเลยไปเริ่มจากตัวอย่างของ repository นี้ spring-data-redis-lettuce ซึ่งยกตัวอย่าง custom LettuceConnectionFactory ไว้ได้โอเคเลย แล้วทำให้เริ่มจากตรงนี้จนมาเป็น ลองผิดลองถูก อ่าน Document ของ LettuceClientConfigurationBuilder จนได้โค้ดของ configuration ข้างล่างนี้

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class RedisClientConfig {
    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public StringRedisTemplate redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
        final StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(lettuceConnectionFactory);
        return template;
    }

    @Bean
    LettuceConnectionFactory lettuceConnectionFactory() {
        LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
                .useSsl()
                .disablePeerVerification()
                .build();
        RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration(redisHost, redisPort);

        return new LettuceConnectionFactory(serverConfig, clientConfig);
    }
}

แล้วใน application.properties จะเหลือแค่

spring.cache.type=redis
spring.redis.host=███████-cache.███████.com
spring.redis.port=6379
spring.cache.redis.time-to-live=18000

Resources