코드의 여백

Spring Boot와 JSch 활용한 ElastiCache SSH 터널링 설계하기

by rowing0328

Intro

이번 글에서는 Spring Boot 애플리케이션에서 JSch 라이브러리를 활용해SSH 터널링을 설정하는 방법을 정리한다.

이 방법은 AWS ElastiCache와 같이 Private 네트워크에 위치한 서비스에 접근해야 할 때 매우 유용하다

 

 

SSH Tunneling

AWS ElastiCache와 같은 인프라는 기본적으로 Private 네트워크안에 존재한다. 따라서 외부에서 접근할 수 없다.

 

이를 해결하기 위해 SSH 터널링을 활용하면,

Bastion 서버를 통해 안전하게 Private 리소스에 접근할 수 있다.

 

의존성 추가

implementation 'com.github.mwiede:jsch:0.2.20'

 

SSH 터널링을 위한 환경 설정 추가

ssh:
  host: ${SSH_HOST}                			 # Bastion 서버의 호스트 주소
  port: ${SSH_PORT}                			 # Bastion 서버의 포트 (기본: 22)
  user: ${SSH_USER}                			 # SSH 접속 사용자명
  private_key_path: ${SSH_PRIVATE_KEY_PATH}  # SSH 개인 키 경로

spring:
  data:
    redis:
      host: ${AWS_ELASITCACHE_REDIS_URL}     # ElastiCache Redis 엔드포인트
      port: ${AWS_ELASITCACHE_REDIS_PORT}    # ElastiCache Redis 포트

application.yml에 SSH 접속 정보Spring Data Redis 설정을 추가한다.
위 설정을 통해 Bastion 서버를 경유해 Private 네트워크의 Redis에 안전하게 접근할 수 있는 환경을 구성한다.

 

 

SshTunnelingInitializer 구현하기

SshTunnelingInitializer는 JSch 라이브러리를 활용해 SSH 세션을 생성하고, Private 네트워크에 있는 데이터베이스 또는 리소스에 접근하기 위한 포트 포워딩을 설정한다.

 

코드 예제

package org.toastit_v2.core.common.infrastructure.ssh;

import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import jakarta.annotation.PreDestroy;
import jakarta.validation.constraints.NotNull;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import org.toastit_v2.core.common.application.code.CommonExceptionCode;
import org.toastit_v2.core.common.application.exception.RestApiException;

@Slf4j
@Validated
@Component
public class SshTunnelingInitializer {

    private final Session session;

    public SshTunnelingInitializer(
            @NotNull @Value("${ssh.host}") String host,
            @NotNull @Value("${ssh.user}") String user,
            @NotNull @Value("${ssh.port}") Integer sshPort,
            @NotNull @Value("${ssh.private_key_path}") String privateKeyPath
    ) {
        try {
            log.debug("SSH 연결을 시작합니다: 사용자={}, 호스트={}, 포트={}, 개인 키 경로={}", user, host, sshPort, privateKeyPath);

            JSch jsch = new JSch();
            jsch.addIdentity(privateKeyPath);
            this.session = jsch.getSession(user, host, sshPort);

            Properties config = new Properties();
            config.put("StrictHostKeyChecking", "no");
            session.setConfig(config);

            log.debug("SSH 세션이 생성되었습니다. 연결을 시도합니다...");
            session.connect();
            log.debug("SSH 연결이 성공적으로 확립되었습니다.");

        } catch (Exception e) {
            log.error("SSH 연결 실패: {}", e.getMessage());
            throw new RestApiException(CommonExceptionCode.SSH_CONNECTION_ERROR);
        }
    }

    @PreDestroy
    public void closeSSH() {
        if (session.isConnected()) {
            session.disconnect();
            log.debug("SSH 연결이 성공적으로 종료되었습니다.");
        }
    }

    public Integer buildSshConnection(String databaseUrl, int databasePort) {
        Integer forwardedPort = null;

        try {
            log.debug("포트 포워딩을 시작합니다...");
            forwardedPort = session.setPortForwardingL(0, databaseUrl, databasePort);
            log.debug("데이터베이스에 성공적으로 연결되었습니다.");
        } catch (Exception e) {
            log.error("포트 포워딩 설정에 실패했습니다: {}", e.getMessage());
            closeSSH();
            throw new RestApiException(CommonExceptionCode.SSH_PORT_FORWARDING_ERROR);
        }

        return forwardedPort;
    }
}
  • SSH 연결 생성
    JSch 객체를 생성하고 addIdentity를 통해 SSH 개인 키를 등록한다.
    session을 생성하여 SSH 세션을 설정하며, StrictHostKeyChecking을 비활성화하여 비공인 호스트에도 연결할 수 있도록 설정한다.
  • 포트 포워딩
    setPortForwarding(0, databaseUrl, databasePort) 메서드를 호출해 로컬 포트를 동적으로 할당하고 원격 리소스에 연결한다.
    성공 시 포워딩된 로컬 포트를 반환한다.
  • 예외 처리
    SSH 연결 실패 또는 포트 포워딩 실패 시 RestApiException을 던져 애플리케이션에서 예외를 처리할 수 있도록 한다.
  • 자원 정리
    @PreDestroy를 활용해 애플리케이션 종료 시 SSH 세션을 안전하게 닫는다.

 

SSH 터널링 사용하기

아래는 SshTunnelingInitializer를 사용해 ElastiCache 또는 데이터베이스에 포트 포워딩을 설정하는 예제이다.

 

코드 예제

import jakarta.validation.constraints.NotNull;
import org.toastit_v2.core.common.infrastructure.ssh.SshTunnelingInitializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.validation.annotation.Validated;

@Validated
@Configuration
public class DevRedisConfig {

    private final String host;
    private final Integer port;

    private SshTunnelingInitializer sshTunnelingInitializer;

    public DevRedisConfig(
            @NotNull @Value("${spring.data.redis.host}") String host,
            @NotNull @Value("${spring.data.redis.port}") Integer port,
            SshTunnelingInitializer sshTunnelingInitializer
    ) {
        this.host = host;
        this.port = port;
        this.sshTunnelingInitializer = sshTunnelingInitializer;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        Integer forwardedPort = sshTunnelingInitializer.buildSshConnection(host, port);

        RedisStandaloneConfiguration standaloneConfiguration = new RedisStandaloneConfiguration();
        standaloneConfiguration.setHostName("localhost");
        standaloneConfiguration.setPort(forwardedPort);

        return new LettuceConnectionFactory(standaloneConfiguration);
    }
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }

}
  • 포트 포워딩 설정
    SshTunnelingInitializer
    를 사용해 SSH 터널링을 설정하고, Private 리소스(ElastiCache 또는 데이터베이스)에 접근하기 위한 포트를 로컬에 포워딩한다.
  • Redis 연결 설정
    RedisStandaloneConfiguration을 사용해 포워딩된 로컬 포트를 기반으로 Redis에 연결한다.
    Redis 템플릿(RedisTemplate)을 구성하여 키와 값을 직렬화한다.
  • 유효성 검사
    @NotNull로 필수 속성을 검증하여 누락된 설정을 방지한다.

 

실행 및 확인

Redis CLI를 사용해 Bastion 서버 접근 확인

ssh -i <SSH_PRIVATE_KEY_PATH> <SSH_USER>@<BASTION_HOST> -L <LOCAL_PORT>:<REDIS_HOST>:<REDIS_PORT>
redis-cli -h 127.0.0.1 -p <LOCAL_PORT> ping

SSH 키와 설정 정보를 사용하여 Bastion 서버를 통해 Redis에 접근 가능한지 확인한다.

 

애플리케이션 실행 및 로그 확인

SSH Tunnel established on localhost:<FORWARDED_PORT> to <REDIS_HOST>:<REDIS_PORT>

애플리케이션을 실행한 뒤 SSH 터널링이 성공적으로 설정되었는지 로그를 확인한다.

성공 시 로그에 다음과 같은 메세지가 포함될 수 있다.

 

Redis 데이터 접근 확인

애플리케이션에서 Redis 관련 작업을 실행하여 동작이 정상적인지 검증한다.

블로그의 정보

코드의 여백

rowing0328

활동하기