[Project] [2] CI/CD Jenkins Pipeline를 이용한 외부 Tomcat 배포

Hayoon·2024년 3월 26일
0

토이 프로젝트를 진행하면서 발생했던 문제에 대한 본인의 생각과 고민을 기록한 글입니다.
기술한 내용이 공식 문서 내용과 상이할 수 있음을 밝힙니다.

외부 Tomcat에 배포를 성공하기까지 무려 43번째만에 성공했다. 어떤 과정에서 이슈가 있었고 해결했는지에 대해 작성하고자 한다.

Tomcat? 외부? 내부?

Tomcat은 자바 웹 애플리케이션을 실행하기 위한 서블릿 컨테이너 및 웹 서버다.
Spring Boot 애플리케이션 개발 시 Tomcat을 사용하는 방법에는 크게 두 가지다.

내장 Tomcat

  • 우리가 흔히 알고 있는 방식으로 Spring Boot는 개발의 편의성과 빠른 프로토타입 개발을 위해 내장 Tomcat 서버를 제공한다. 이 방법을 사용하면 별도의 Tomcat 설치 없이 애플리케이션을 실행할 수 있다. 내장 Tomcat을 사용하는 경우, Spring Boot는 일반적으로 JAR 파일로 패키징되며 배포가 매우 간편하다.

외부 Tomcat

  • 외부 Tomcat을 사용하는 경우, 개발자는 별도의 Tomcat 인스턴스를 설치하고 관리해야 한다. 애플리케이션은 WAR 파일로 패키징되며, 이 WAR 파일을 Tomcat의 webapps 디렉토리에 배포해야 한다. 이 방식은 애플리케이션 서버의 설정을 더 세밀하게 조정하고 싶을 때, 또는 여러 웹 애플리케이션을 하나의 Tomcat 인스턴스에서 실행하고 싶을 때 유리하다.

Tomcat 역할 설정

Tomcat의 conf/tomcat-users.xml 파일에서 사용자에게 여러 역할을 부여할 수 있다. 이 중 manager-script 역할은 스크립트를 통한 애플리케이션 배포와 관리를 가능하게 해주는 역할로 Maven이나 Gradle 같은 빌드 도구를 사용하여 CI/CD 파이프라인에서 자동으로 애플리케이션을 배포할 때 이 역할이 필요하기에 설정해야한다.

이전 글에서 Jenkins Pipeline으로 톰캣 서버에 배포하는 준비과정은 마쳤다.
buildtestdeploy 과정에서 deploy에서 발생한 문제에 대해서 얘기하려고 한다.

Tomcat max-file-size 문제

Tomcat 9.0.87 에 war 파일을 배포하는 동안 Apache Tomcat 서버의 업로드 제한 크기와 관련하여 다음과 같은 문제에 직면했다.

Caused by: org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException the request was rejected because its size (83990938) exceeds the configured maximum (52428800)
<multipart-config>
  <!-- 150 MiB max -->
  <max-file-size>157286400</max-file-size>
  <max-request-size>157286400</max-request-size>
  <file-size-threshold>0</file-size-threshold>
</multipart-config>

vi webapps/manager/WEB-INF/web.xml를 보니 기본 업로드 제한은 50MB로 설정되어 있었고, 내 project WAR file 크기는 80.1MB로 Tomcat 업로드 파일 크기 제한을 기본값에서 150MB로 확장했다. (추가: EC2서버 t2.small로 scale-up)

Tomcat 서버 App에서 외부 서버 DB 접속 문제

에러를 보면 masterFlyway가 ddl-script를 DB에 적용하려고 하는데 DB 커넥션을 획득하지 못하고 있다. link failure로 빈생성 문제가 발생했다. ( sudo cat logs/catalina.2024-03-21.log에서 확인)
생각해보면 당연하다. Tomcat은 AWS, DB는 NCP에 도커 컨테이너로 실행되고 있는 상태이니 말이다.

상황은 다음과 같다. Naver Cloud Platform(이하 NCP) 서버에 Docker Compose로 MySQL, PostgreSQL, Redis 등 DB들을 컨테이너 별로 도커를 실행 중이다. (호스트 포트 : 내부 포트 참조)

SSH Tunneling

SSH 터널링을 통해 AWS와 같은 원격 서버와 NCP 내의 도커 컨테이너 또는 사설망 내부 서비스 간에 안전한 통신 통로를 구성할 수 있다.

SSH 터널링은 안전한 SSH 연결을 통해 원격 컴퓨터와 로컬 컴퓨터 사이에 데이터를 전송하는 방법이다.
SSH 터널은 로컬 애플리케이션(예: 톰캣 서버)이 마치 로컬 네트워크 내에 해당 데이터베이스 서버나 캐시 서버가 존재하는 것처럼 통신할 수 있게 한다. 이는 외부 네트워크를 통한 직접 접근이 허용되지 않는 환경에서 적합하다.

@Component
@Slf4j
public class SSHConnection {
    private final String host;
    private final Integer port;
    private final String sshUser;
    private final String sshPw;

    public SSHConnection(@Value("${ssh.host}") final String host,
                         @Value("${ssh.port}") final Integer port,
                         @Value("${ssh.user}") final String sshUser,
                         @Value("${ssh.password}") final String sshPw) {
        this.host = host;
        this.port = port;
        this.sshUser = sshUser;
        this.sshPw = sshPw;
    }

    private Session session;

    @PreDestroy
    public void closeSSH() {
        if (session != null) {
            session.disconnect();
        }
    }

    public void buildSshConnection() {
        try {
            Properties config = new Properties();
            /** 
            	원격 서버의 호스트 키가 알려진 호스트 목록에 없는 경우
	            클라이언트 호스트 키를 알려진 호스트 목록에 자동으로 추가
            **/
            config.put("StrictHostKeyChecking", "no"); 
            JSch jsch = new JSch();
            session = jsch.getSession(sshUser, host, port);
            session.setPassword(sshPw);
            session.setConfig(config);

            session.connect();

            session.setPortForwardingL(3306, "localhost", 3306);// MySQL Master
            session.setPortForwardingL(3307, "localhost", 3306);// MySQL Slave1
            session.setPortForwardingL(3308, "localhost", 3306);// MySQL Slave2
            session.setPortForwardingL(5432, "localhost", 5432);// PostgreSQL
            session.setPortForwardingL(6379, "localhost", 6379);// Redis

            log.info("SSH 연결 성공");

        } catch (JSchException e) {
            log.info("SSH 연결 실패: " + e.getMessage());
            e.printStackTrace();
        }
    }

JSch 라이브러리를 사용하여 원격 서버에 SSH 연결을 구성하고, 로컬 포트 포워딩을 설정하여 MySQL, PostgreSQL, Redis 등의 서비스에 대한 터널을 만든다. host의 지정된 포트(예: 3306, 3307, 3308, 5432, 6379)를 사용하여 해당 서비스에 접근할 때, 실제로는 안전한 SSH 연결을 통해 원격 서버의 해당 서비스에 접근할 수 있다.

SSH Tunneling, DataSourceConfig 순서

SSH 터널링을 통해 이제 NCP 서버로 진입하여 포트포워딩을 통해 도커 컨테이너에 있는 DB 커넥션 연결이 가능해졌다. 하지만, SSH Tunneling과 DataSourceConfig의 순서에 따른 문제가 있다.

시나리오

  • DataSourceConfig가 적용되고 SSH Tunneling 접속 시
    1. DataSourceConfig는 DataSource 정보를 토대로 DB 연결을 시도한다.
    2. DataSource는 localhost:port로 정보가 주어진다.
    3. 터널링을 하지 않았으므로, 톰캣 서버를 localhost로 인지해 톰캣 서버에 있는 DB를 찾는다.
    4. 당연히 톰캣에는 DB가 없으므로 DB Connection link failure 에러 발생 (이후 터널링 접속)

  • SSH Tunneling 접속 후 DataSourceConfig가 적용
    1. SSH 터널링을 통해 NCP서버로 진입해 localhost는 톰캣이 아닌 NCP다.
    1. DataSourceConfig는 DataSource 정보를 토대로 DB 연결을 시도한다.
    2. DataSource는 localhost:port로 정보가 주어진다.
    3. NCP 서버를 localhost로 인지해 NCP 서버에서 포트포워딩을 통해 DB 접속 성공

시나리오 2번대로 SSH Tunneling 후 DataSource가 생성되어야 한다.

@Configuration
@Slf4j
@RequiredArgsConstructor
@EnableConfigurationProperties(DatabaseProperties.class)
public class DataSourceConfig {

    private final SSHConnection sshConnection;

    @PostConstruct
    public void initializeSshConnection() {
        sshConnection.buildSshConnection();
        log.info("SSH 접속이 초기화되었습니다.");
    }

    ...

    @Bean("masterDataSource")
    public DataSource createMasterDataSource(DatabaseProperties databaseProperties) {
        log.info("Master DB detail: {}", databaseProperties.getMaster());
        return createDataSource(databaseProperties.getMaster());
    }
  1. Bean 인스턴스화: DataSourceConfig 클래스의 인스턴스가 생성
  2. 의존성 주입: DataSourceConfig 인스턴스에 필요한 의존성들이 주입
  3. 초기화 콜백 실행: @PostConstruct이 붙은 메소드가 실행되어 SSH 접속 초기화 작업이 수행된다. (DataSourceConfig 빈의 생성 및 의존성 주입이 완료된 직후)
  4. Bean 메소드 실행: 스프링 컨테이너가 해당 메서드로부터 생성된 빈을 필요로 할 때 메소드가 호출되어 Master DataSource Bean 생성

스프링 컨테이너 생성 → 빈 생성 → 의존관계 주입 → 초기화 콜백 → 사용 → 소멸전 콜백 → 스프링 종료

docker-compose.yml

version: '3'
services:
  mysql_master:
    image: mysql:8.0
    env_file:
      - ./master/mysql_master.env
    container_name: "mysql_master"
    restart: "no"
    ports:
      - 3306:3306
    volumes:
      - ./master/config_file.cnf:/etc/mysql/conf.d/mysql.conf.cnf
    networks:
      - db_network

  mysql_slave1:
    image: mysql:8.0
    env_file:
      - ./slave/mysql_slave.env
    container_name: "mysql_slave1"
    restart: "no"
    ports:
      - 3307:3306
    depends_on:
      - mysql_master
    volumes:
      - ./slave/config_file.cnf:/etc/mysql/conf.d/mysql.conf.cnf
    networks:
      - db_network

  mysql_slave2:
    image: mysql:8.0
    env_file:
      - ./slave2/mysql_slave.env
    container_name: "mysql_slave2"
    restart: "no"
    ports:
      - 3308:3306
    depends_on:
      - mysql_master
    volumes:
      - ./slave2/config_file.cnf:/etc/mysql/conf.d/mysql.conf.cnf
    networks:
      - db_network

network:
  db_network: 

DB 연동 결과

Redis 초기화 시점

RDB를 해결하니 이제는 Redis 접속 문제가 발생했다. RedisConfig는 DataSourceConfig랑 @Configuration이 별개이다.
따라서, SSH Tunneling 후 DataSource 빈 생성 & RedisConfig 빈 생성 순이어야 한다.

@Configuration
@Slf4j
@DependsOn("dataSourceConfig")
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

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

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(host, port);
        return lettuceConnectionFactory;
    }
    
    ...
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
        return Redisson.create(config);
    }

redisConfig 빈은 @DependsOn({"dataSourceConfig"})을 사용해 dataSourceConfig 빈이 먼저 생성되어야 함을 명시하고 있다. dataSourceConfig 빈의 생성이 redisConfig 빈의 생성보다 앞서 실행됨을 보장하므로 결국 SSH 터널링 후 레디스도 NCP서버에서 localhost:6379로 DB 커넥션을 할 수 있게 되었다.

배포 결과

드디어 초록색으로 도배가 됐다.Tomcat 서버 접속 후 API 테스트를 해보자.

다음 글에서는 Tomcat App 실행 중 새로 배포할 경우에 대한 방안 그리고 HashiCorp/Vault로 데이터 암호화와 접근 제어를 통해 비밀 정보 관리에 대해 기록하겠다.

profile
Junior Developer

0개의 댓글

관련 채용 정보