[SpringBoot,Nginx] 무중단 배포

Hyebin Lee·2022년 2월 18일
8

졸업프로젝트

목록 보기
2/2

무중단 배포란?

이전에 스프링부트 프로젝트를 Travis CI를 활용하여 배포 자동화 환경을 구축하였기 때문에 Master 브랜치에 Push만 되면 자동으로 빌드와 테스트 그리고 배포까지 이루어진다.
그러나 배포하는 시간 동안 어플리케이션이 종료가 된다는 문제점이 있다.
긴 시간은 아니지만 새로운 Jar가 실행되기 전까지 기존의 jar를 종료시키기 때문에 서비스가 일부 시간동안 중단된다.
이렇게 서비스를 중단시키지 않고 배포하는 것을 무중단 배포라고 한다.

물론 우리가 개발하는 졸업프로젝트는 상업용이 아니기 때문에 배포 시간동안 서비스가 중단된다고 큰 문제가 일어나는 것은 아니지만 그래도 이왕 개발하는거 제대로 해보고 싶은 마음에 무중단 배포도 진행해보도록 하겠다.

Nginx

Nginx를 이용해서 무중단 배포를 하려는 이유는 가장 저렴하기 때문이다.
기존에 쓰던 EC2를 그대로 적용하면 돼서 배포를 위해 AWS EC2 인스턴스를 하나 더 생성할 필요가 없다.

무중단 배포의 운영 과정은 다음과 같다.
1. EC2(리눅스 서버)에 Nginx 1대와 스프링부트 jar 2대를 사용한다.
-> Nginx는 80(http), 443(https) 포트를 사용하고 스프링부트1(8081), 스프링부트2(8082) 각각 사용한다.
2. 사용자는 서비스 주소로 접속한다 (80 또는 443 포트)
3. Nginx는 사용자의 요청을 받아 현재 연결된 스프링부트로 요청을 전달한다.
4. 배포가 필요하면 Nginx와 연결되지 않은 스프링부트로 배포한다.
5. 배포가 끝나고 정상적으로 배포를 한 스프링부트가 구동중인지 확인한다.
6. 5에서 확인한 스프링부트가 정상 구동중이면 nginx reload를 통해 해당 스프링부트의 포트를 nginx가 연결하도록 한다.
7. 만약 배포시 문제가 생겨서 rollback이 필요하면 ngix가 배포전에 연결되었던 스프링부트를 다시 연결하면 된다.

리버스 프록시

Nginx가 외부 요청을 받아 뒷단 서버로 요청을 전달하는 행위를 리버스 프록시라고 한다.
이런 리버스 프록시 서버(Nginx)는 요청을 전달하고 실제 요청에 대한 처리는 뒷단의 웹서버들이 처리한다.
대신 외부 요청을 뒷단 서버들에게 골고루 분배한다거나 한 번 요청왔던 js,image,css 등은 캐시하여 리버스 프록시 서버에서 바로 응답을 주거나 등의 여러 장점이 있다.

무중단 배포 구축하기

Nginx 설치

ec2 리눅스 서버에서 다음과 같은 명령어를 실행한다.

sudo yum install nginx  // nginx 설치
sudo service nginx start //nginx 시작
ps -ef | grep nginx //nginx 구동 확인

이제 EC2 Public DNS를 복사한 후 Nginx가 잘 노출되는지 브라우저에 url을 입력해보면

다음과 같이 Nginx가 노출된다.

이 과정은 쉽게 말해 ec2로 유저가 접근하는 url이 nginx로 덮어씌어진 것이라고 생각하면 된다.
이제 nginx가 자기 자신이 아니라 실행중인 스프링부트 프로젝트를 바라보게 해서 자신 위에 다시 스프링부트 프로젝트를 씌우도록 설정하면 된다.

sudo vi /etc/nginx/nginx.conf

다음 명령어를 통해 nginx 설정 파일을 열어서

server{
...
	#load configuration files ..
    include /etc ...
    
	//📌추가
    location / {
    	proxy_pass http://localhost:8081; //요청이 오면 http://localhost:8081로 전달
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header Host $http_host;
    }
    ...
}

다음 위치에 다음 코드를 추가하면 된다.
수정을 마치고 Nginx를 재시작한다.

sudo service nginx restart

다시 브라우저 url를 접속해보면 예전에 nginx 설치 이전처럼 바로 해당 url을 스프링부트 프로젝트가 실행됨을 확인할 수 있다.

set1, set2 Profile 설정

스프링부트는 .properties.yml 파일을 통해 여러 설정값을 관리한다.
이러한 값을 통해 실제 서비스에서 로컬, 개발서버, 운영서버 등으로 환경이 분리되어 접속하는 DB값, 외부 API주소 등이 서로 달라진다.
하나의 프로젝트의 코드로 환경을 이와 같이 구분할 수 있게 하는 것이 profile 설정 기능이다.
예를 들어 spring.profiles: local, dev, real등으로 profile을 설정하고 스프링 프로젝트를 실행시킬 때 real 환경으로 실행하고 싶으면 nohup java -jar -Dspring.profiles.active=real 와 같이 사용하면 된다.

먼저 실행 중인 프로젝트의 Profile이 뭔지 확인할 수 있는 API를 하나 생성한다.
기존의 APIController 클래스에 API 메소드를 추가했다.

@RestController
@RequiredArgsConstructor
public class ArticleApiController {

    private final ArticleService articleService;
    private final Environment env;

    //profile 조회
    @GetMapping(value="/profile")
    public String getProfile(){
        return Arrays.stream(env.getActiveProfiles())
                .findFirst()
                .orElse("");
    }
    ....

그리고 test 폴더 안에 있는 yml 파일에서 local profile을 active 하도록 설정했다.

spring:
  profiles:
    active: local
---
spring:
  profiles: local
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true
  jpa:
    hibernate:
      dialect: org.hibernate.dialect.H2Dialect
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        show_sql: true


logging:
  level:
    org.hibernate.sql: debug

다음으로 Test 코드에 /profile url로 받아온 현재 구동중인 profile이 local인지 확인하는 코드를 짜고 실행했다.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class NewsumApplicationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void Profile확인(){
        //when
        String profile = this.restTemplate.getForObject("/profile",String.class);
        //then
        Assertions.assertThat(profile).isEqualTo("local");
    }

}

실행하면 무사히 test가 pass됨을 확인할 수 있다!

profile값을 반환하는 API가 완성되었으니 운영 환경의 yml 파일을 추가해보도록 하자.
운영 환경의 yml은 프로젝트 외부에 생성한다.
ec2 리눅스 환경과 IntelliJ 터미널(local 환경)에서 본인이 원하는 디렉토리에 real-application.yml을 생성한다.
real-application.yml에 아래 코드를 입력한다.

---
spring:
  profiles: set1
server:
  port: 8082

---
spring:
  profiles: set2

server:
  port: 8083

즉 set1은 8082 포트를, set2는 8083 포트를 갖게 했다.


프로젝트 운영환경의 yml을 프로젝트 외부에 놓는 이유
github 같이 오픈된 공간에 운영환경의 설정 (Database 접속 정보, 세션저장소 접속정보, 암호화 키 등등) 을 넣는 것은 바람직하지 않기 때문이다.


외부에 있는 이 파일을 프로젝트가 호출할 수 있도록 Application.java 코드를 아래와 같이 변경한다.

@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class NewsumApplication {

    public static final String APPLICATION_LOCATIONS = "spring.config.location="
            + "classpath:application.yml,"
            + "./app/config/newsum/real-application.yml";
    public static void main(String[] args) {
        new SpringApplicationBuilder(NewsumApplication.class)
                .properties(APPLICATION_LOCATIONS)
                .run(args);
    }

}

스프링부트 프로젝트가 실행될 때, 프로젝트 내부에 있는 application.yml과 외부에 있는 real-application.yml을 모두 불러오도록 했다.

IntelliJ에서 command+shift를 사용해서 Edit Configuration을 검색하고 프로젝트의 main Application을 선택한 후 좌측 상단의 copy 버튼을 클릭해서 설정 내용을 복사한다.
복사한 설정 내용에서 이름을 [set1]NewsumApplication으로 변경하고
실행 환경을 main(yml 파일이 있는 자리), Active Profiles를 set1 (active 될 profile 명)으로 설정한다.
이제 새로 생성된 실행환경을 선택하고 실행하면 set1의 yml 설정대로 port 8082에서 프로젝트가 실행되는 것을 확인할 수 있다.


삽질로그 : ec2에서 8081 port( local mode) 실행 안됨

이유는 위의 코드에 있었다.
"./app/config/newsum/real-application.yml" 이 부분에서 real-application.yml 찾아서 실행시켜야 하는데
해당 경로가 local 환경에서 프로젝트를 실행했을 때랑 ec2에서 실행했을 때랑 파일이 있는 위치가 달랐던 것이다.
바보같이 ec2에 냅다 app/config/newsum 부터 만들고 파일을 그곳에 작성했는데
실제로 프로젝트가 실행되는 deploy.sh 파일은 app/travis 경로 안에 있었다.
따라서 real-application.yml 파일은 app/travis/app/config/newsum 안에 넣어주어야 한다.

🌟🧚‍♀️ 배포가 원활히 이루어지지 않았을 때 / 배포된 프로젝트 접근이 제대로 안될 떄 꿀팁
배포 프로젝트 실행 로그를 확인해야 한다.
로그는 마찬가지로 app/travis 경로 안에 nohup.out 파일로 생성되어 있다.
간혹 원인을 알 수 없지만 nohup.out이 덮어쓰기가 안되는 경우가 있는데 이 경우 nohup.out을 삭제하고 수동으로 ./deploy.sh코드를 쳐서 프로젝트를 실행시키면 새로운 nohup.out 파일이 생겨 로그를 확인할 수 있다.

  • 또한 왜 갑자기 헷갈렸는지 모르겠지만 ㅠㅠ local 에서 프로젝트를 돌릴 때에는 해당 (local) ip가 rds 보안그룹 바운더리에 포함되어야 있어야 한다. 이거 까먹어서 엄청 뻘짓함

배포 스크립트 작성

1번째 배포 디렉토리로 git,
2번째 배포 디렉토리로 travis,
3번째 배포 디렉토리로 nonstop을 지정하도록 했다.
그래서 nonstop directory를 만들고 배포스크립트가 정상적으로 되는지 테스트해보기 위해 기존에 받아둔 스프링 프로젝트.jar를 복사해서 실행해본다.

mkdir ~/app/nonstop/springboot-webservice
mkdir ~/app/nonstop/springboot-webservice/build
mkdir ~/app/nonstop/springboot-webservice/build/libs
cp ~/app/travis/build/build/libs/*.jar ~/app/nonstop/springboot-webservice/build/libs/

테스트할 jar가 있으니 jar파일을 모아둘 디렉토리를 app/nonstop/jar에 생성하고
vim ~/app/nonstop/deploy.sh 파일을 아래의 코드로 생성한다.

#!/bin/bash
BASE_PATH=/home/ec2-user/app/nonstop
BUILD_PATH=$(ls $BASE_PATH/springboot-webservice/build/libs/*.jar)
JAR_NAME=$(basename $BUILD_PATH)
echo "> build 파일명: $JAR_NAME"

echo "> build 파일 복사"
DEPLOY_PATH=$BASE_PATH/jar/
cp $BUILD_PATH $DEPLOY_PATH

echo "> 현재 구동중인 Set 확인"
CURRENT_PROFILE=$(curl -s http://localhost/profile)
echo "> $CURRENT_PROFILE"

# 쉬고 있는 set 찾기: set1이 사용중이면 set2가 쉬고 있고, 반대면 set1이 쉬고 있음
if [ $CURRENT_PROFILE == set1 ]
then
  IDLE_PROFILE=set2
  IDLE_PORT=8082
elif [ $CURRENT_PROFILE == set2 ]
then
  IDLE_PROFILE=set1
  IDLE_PORT=8081
else
  echo "> 일치하는 Profile이 없습니다. Profile: $CURRENT_PROFILE"
  echo "> set1을 할당합니다. IDLE_PROFILE: set1"
  IDLE_PROFILE=set1
  IDLE_PORT=8081
fi

echo "> application.jar 교체"
IDLE_APPLICATION=$IDLE_PROFILE-springboot-webservice.jar
IDLE_APPLICATION_PATH=$DEPLOY_PATH$IDLE_APPLICATION

ln -Tfs $DEPLOY_PATH$JAR_NAME $IDLE_APPLICATION_PATH

echo "> $IDLE_PROFILE 에서 구동중인 애플리케이션 pid 확인"
IDLE_PID=$(pgrep -f $IDLE_APPLICATION)

if [ -z $IDLE_PID ]
then
  echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> kill -15 $IDLE_PID"
  kill -15 $IDLE_PID
  sleep 5
fi

echo "> $IDLE_PROFILE 배포"
nohup java -jar -Dspring.profiles.active=$IDLE_PROFILE $IDLE_APPLICATION_PATH &

echo "> $IDLE_PROFILE 10초 후 Health check 시작"
echo "> curl -s http://localhost:$IDLE_PORT/actuator/health "
sleep 10

for retry_count in {1..10}
do
  response=$(curl -s http://localhost:$IDLE_PORT/actuator/health)
  up_count=$(echo $response | grep 'UP' | wc -l)

  if [ $up_count -ge 1 ]
  then # $up_count >= 1 ("UP" 문자열이 있는지 검증)
      echo "> Health check 성공"
      break
  else
      echo "> Health check의 응답을 알 수 없거나 혹은 status가 UP이 아닙니다."
      echo "> Health check: ${response}"
  fi

  if [ $retry_count -eq 10 ]
  then
    echo "> Health check 실패. "
    echo "> Nginx에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi

  echo "> Health check 연결 실패. 재시도..."
  sleep 10
done

참고로 curl 에서 -s옵션은 상태진행바를 노출시키지 않는 옵션이다.
따라서 상태를 확인하고 싶으면 해당 옵션을 제거하면 된다.

또한 우리는 기존에 real-application.yml 파일을 상대경로로 주었기 때문에
nonstop 폴더에서 실행되는 jar 파일의 상대 경로에도 real-application.yml 파일을 알맞는 위치에 복사해서 넣어주어야 한다.

그리고 /health 는 스프링부트 프로젝트의 상태를 확인해주는 기능으로
이는 프로젝트 의존성에 org.springframework.boot:spring-boot-starter-actuator 를 추가해주어야 동작한다.

이제 deploy.sh를 실행해보면 set1을 profile로 하는 프로젝트가 실행된다!

Nginx 동적 프록시 설정

이제 nginx가 기존에 바라보던 profile의 반대편을 바라보도록 변경하는 과정이 필요하다.

cd /etc/nginx
ll

위의 명령어로 Nginx 설정쪽으로 간 뒤
sudo vim /etc/nginx/nginx.conf 명령어로 Nginx가 동적으로 Proxy pass를 변경할 수 있도록 설정파일을 생성한다.

 include /etc/nginx/conf.d/service-url.inc;

          location / {
                  proxy_pass $service_url;

코드는 위와 같이 넣어주면 된다.
그리고 service-url.inc파일을 생성한다.

sudo vim /etc/nginx/conf.d/service-url.inc

아래와 같이 코드 생성 
-------
set $service_url http://127.0.0.1:8082; //📌set1의 port 넘버 

그리고 sudo service nginx restart로 재시작한 뒤 테스트를 위해 curl -s localhost/profile을 실행하면 Nginx로 요청하면 set1로 Proxy가 가는 것을 확인할 수 있다.

Nginx 스크립트 작성

이제 배포마다 Nginx가 바라보는 profile을 자동으로 변경하도록 스위치 스크립트를 작성하면 된다.
nonstop 폴더의 deploy.sh 파일이 있는 위치에 switch.sh를 생성한다.

#!/bin/bash
echo "> 현재 구동중인 Port 확인"
CURRENT_PROFILE=$(curl -s http://localhost/profile)

# 쉬고 있는 set 찾기: set1이 사용중이면 set2가 쉬고 있고, 반대면 set1이 쉬고 있음
if [ $CURRENT_PROFILE == set1 ]
then
  IDLE_PORT=8083
elif [ $CURRENT_PROFILE == set2 ]
then
  IDLE_PORT=8082
else
  echo "> 일치하는 Profile이 없습니다. Profile: $CURRENT_PROFILE"
  echo "> 8082을 할당합니다."
  IDLE_PORT=8082
fi

echo "> 전환할 Port: $IDLE_PORT"
echo "> Port 전환"
echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" |sudo tee /etc/nginx/conf.d/service-url.inc

PROXY_PORT=$(curl -s http://localhost/profile)
echo "> Nginx Current Proxy Port: $PROXY_PORT"

echo "> Nginx Reload"
sudo service nginx reload

이것이 잘 작동하는지 확인하기 위해 일단 deploy.sh를 실행해서 set2도 실행 상태로 올리고
switch.sh를 실행해서 웹 브라우저에서 실시간으로 /profile uri를 보며 활동중인 profile이 변경되는 것을 확인한다.

이제 배포시마다 switch.sh가 실행되도록 deploy.sh의 코드 가장 마지막에 아래 코드를 추가한다.

echo "> 스위칭"
sleep 10
/home/ec2-user/app/nonstop/switch.sh

이제 모든 준비가 끝났다!

배포에 적용

이제 배포 할 때마다 위와 같은 switching이 일어나도록 배포와 연동하면 된다.
일단 기존 nonstop 폴더에 있던 jar 파일을 모두 제거해준다.
그리고 원래 travis 폴더로 배포시 deploy.sh 파일이 실행되도록 설정되어 있었던 프로젝트의 execute-deploy.sh 파일을 nonstop 폴더의 deploy.sh 파일이 실행되도록 경로를 변경해준다.

그리고 프로젝트 폴더의 appspec.yml도 경로를 /nonstop/springboot-webservice/로 변경한다.

이렇게 모든 배포 과정이 끝났다!
아직 이해가 안가는 부분도 있고 많이 어려워서 헤맸지만
배포는 자전거타기와 같아서 한 번 익숙해지면 점점 속도가 붙고 능숙하게 해낼 수 있다고 한다!
앞으로는 보다 쉽고 능숙하게 배포할 수 있기를 😭😭

1개의 댓글

comment-user-thumbnail
2022년 5월 20일

좋은 내용 잘 보고 갑니다
감사합니다.

답글 달기