[재능교환소] CI/CD 무중단 배포

10000JI·2024년 5월 24일
0

프로젝트

목록 보기
3/14
post-thumbnail

그동안 프로젝트를 진행하면서 ngork로 프론트엔드와 통신하며 데이터를 주고 받았었다.

프로젝트가 절반 정도 진행된 지금, 배포 과정에서 발생할 수 있는 잠재적인 문제를 해결하기 위해 클라우드에 배포하기로 결정했다.

🦘 무중단 배포를 진행한 이유

CI/CD 환경이 구축되면 Master 브랜치에 Push가 되었을 시에 자동으로 빌드->테스트->배포가 진행된다.

하지만 배포하는 그 짧은 시간 동안 돌아가던 서비스는 종료가 되는 문제가 있다.

패키징 후 Jar 파일이 생성되어 실행되는 시간이 있기에 그동안은 멈출 수 밖에 없는 것이다.

따라서 이러한 문제를 해결하기 위해 하나의 AWS EC2 인스턴스에 Nginx 서버를 가동시켜 포트번호를 8081 포트와 8082 포트로 나눠 스프링부트를 가동시킬 것이다.

  1. nignx가 클라이언트에게 제공하는 서버 역할을 하며, 요청 값에 대한 응답을 스프링에서 전달한다.

  2. jenkins가 빌드된 결과물을 테스트하고 배포가 진행되면 nignx에선 스프링 서버 중 하나를 재시동하여 실행하게 된다.

  3. 따라서 jenkins는 ci/cd를 제공하고, nginx는 서버단을 맡게 되며 배포된 결과물에 따라 포트번호를 8081번에서 8082번으로 변경하거나 8082번에서 8081번으로 변경하는 것이다.
    ( 변경되는 포트번호가 새롭게 빌드된 결과물 제공 )

리버스 프록시란?

리버스 프록시

Nginx가 외부의 요청을 받아 클라이언트와 내부 서버를 연결하는 행위를 리버스 프록시라고 한다.

클라이언트가 서버를 호출할 때 리버스 프록시를 호출하고 프록시 서버가 서버를 요청하여 응답을 클라이언트에게 전달하는 방식이다.

즉, 애플리케이션 서버 앞에 위치하여 클라이언트가 서버를 요청할 때 리버스 프록시를 호출하고, 리버스 프록시가 서버로부터 응답을 전달받아 다시 클라이언트에게 전송하는 것을 말한다.

이런 리버스 프록시 서버(Nginx)는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 웹서버들이 처리한다.

따라서 서버가 누구인지 감추는 역할도 동시에 한다.

호스트 PC의 Docker 컨테이너에서 Jenkins 실행

참고로 젠킨스를 초반엔 EC2 인스턴스에 설치하여 진행하였으나, AWS 프리티어 계정을 사용하기에 t2.micro로 구축할 수 밖에 없었다.

용량이 한참 작아 ec2 인스턴스에 swap memory 설정했는데도 불구하고 빌드 후 jar 파일이 생성되는 과정 중에 속도 저하로 멈춤 현상이 발생하였다.

그래서 ec2 인스턴스 대신, 호스트 pc에서 젠킨스 도커 컨테이너를 생성하여 로컬에서 실행하였다.

백엔드 개발 인원이 다수면 EC2에 올려야 되겠지만, 혼자 백엔드를 담당하고 있기에 로컬에서 진행해도 문제가 없었다.

🦔 스프링 profile 세팅

위에서 설명한 nignx에서 구동되는 두 개의 스프링을 구분할 수 있도록 profile을 세팅해보자.

먼저 스프링 프로젝트 안에 WebRestController 클래스를 생성하고, 다음과 같이 코드를 작성하자.

package place.skillexchange.backend;

import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;

@RestController
@RequiredArgsConstructor
public class WebRestController {
    private final Environment env;

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

이 코드는 현재 동작중인 프로파일의 이름을 반환하게 된다.

현재 프로젝트 [ 재능교환소 ]는 application.yml에 설정 정보들이 들어있고, application-sub.yml에 rds설정, s3 설정, jwt key 설정 .. 등 설정 정보들이 들어있다.

물론 application-sub.yml은 노출되어서는 안되는 정보들이 들어있기 때문에 github에는 올릴 수 없도록 .gitignore에 기록해놓았다.

내 application.yml 속 profile 다음과 같이 설정하였다.

spring:
  profiles:
    include: sub
  	group:
  		set1: set1
    	set2: set2

기본 프로파일 외에 include 키워드로 sub이라는 프로파일을 함께 사용하도록 하였고, group 키워드로 set1, set2 프로파일을 애플리케이션이 필요에 따라 사용할 수 있도록 만들었다.

nginx ec2 인스턴스에 접속한 뒤 다음 명령어로 폴더 2개를 만들어주자.

sudo mkdir ~/app
sudo mkdir ~/app/config

그리고 config 폴더에 prod-application.yaml 파일을 vi로 생성하여 저장해주자. 참고로 set1, set2 프로파일 및 포트번호를 적기 전에 위에 db 설정과 같은 sub 프로파일에 있는 중요정보를 적어주었다.

[sub 프로파일에 있던 중요 정보를 기록해야 한다.]
---
spring:
  config:
    activate:
      on-profile: set1
server:
  port: 8081

---
spring:
  config:
    activate:
      on-profile: set2
server:
  port: 8082

yaml 파일은 한 문서 내에서 여러 개의 문서를 적을 수 있으며, 각각을 ---로 구분할 수 있다.

프로파일 설정을 set1으로 주면 8081 포트로 동작하고 set2로 주면 8082 포트로 동작할 것이다.

이렇게 하면 1차적인 작업은 끝났다.

🐥 nginx 설치 및 실행

sudo apt-get install nginx
sudo service nginx start
sudo service nginx status

nginx를 설치하고, 실행, active로 실행되는지 확인한다.

nginx는 포트 80번으로 구동된다.

nginx가 구동되는 ec2의 보안 그룹에서 80번 포트를 개방하는 것도 잊지말자.

🐑 nginx 설정하기

nginx가 스프링에게 사용자의 요청을 전달하기 위해 몇 가지 설정을 해줘야 한다.

해당 명령어로 설정 파일을 열어

sudo vim /etc/nginx/sites-enabled/default

다음 두 줄을 사진과 같이 추가한다.

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

그리고 /etc/nginx/conf.d/ 디렉터리 안에 service-url.inc 파일을 생성하고 다음과 같이 작성한다.

이렇게 설정하면 nginx는 사용자의 요청을 스프링에게 전달하게 된다.

🦡 Jenkins에서 빌드 후 배포해보기

다음은 localhost에서 도커 컨테이너로 구동 중인 jenkins에 접속하기 위해

docker exec -it jenkins-server /bin/bash

명령어를 cmd창에 입력하여 접속한다.

그리고 젠킨스 gui 웹페이지도 접속해주자.

github의 repository에 있는 코드들을 가져오는 작업을 간단하게 jenkins를 통해 해보자.

새로운 Item을 클릭하여 이름은 testNginx라고 적어주었고, Freestyle project를 클릭한다.

구성에서 내려다가보면 소스 코드 관리가 있다.

여기에 가져오고자 하는 gitHub 주소를 적어준다.

그리고 저장을 누르고 지금 빌드를 눌러주면 Build History에 첫번째 잡이 실행된다.

#1을 눌러준 뒤 console output을 확인해보면 저장된 워크스페이스의 위치를 알려주며 Success 가 뜨게 된다.

cli로 확인해보면 testNginx라는 폴더가 생성되었고

폴더 내부에는 github repository에서 그대로 가져온 것을 확인할 수 있다.

여기서 짚고 넘어가야 되는 부분이 있다.

앞에서 나는 [ 재능교환소 ]에서 application.yml에 설정 정보들이 들어있고, application-sub.yml에 rds설정, s3 설정, jwt key 설정 .. 등 설정 정보들이 들어있다고 말했다.

만약 jenkins에서 빌드된 후 배포과정이 진행될 때 jar파일이 생성됨에 있어 application-sub.yml이 깃허브엔 없기 때문에 현재 폴더 내에도 없다.

이러면 분명 에러가 발생하고 jar파일이 정상적으로 생성되지 못할 것이다.

따라서 git에서 가져온 프로젝트 내부에 resources 밑에 application-sub.yml을 vim으로 생성해 주겠다.

그리고 다시 jenkins로 돌아가 대시보드로 돌아가 방금 만들었던 Item은 삭제시켜 주고, 이번엔 Maven project를 선택해 만들어주자.

여기서 이름은 동일하게 testNginx라고 하였다. 워크스페이스에서 생성되는 폴더 이름이 변경될 우려가 있으니 동일이름으로 적어주었다.

동일하게 Git에 repository 주소를 적어주고

Build에 Root POM은 pom.xml, Goals and options에는 clean compile package 라고 적어준 뒤 저장을 눌러 지금 빌드해보자.

cli로 돌아와 /var/jenkins_home/workspace/testNginx에서 확인해보면 전에 없던 target 폴더가 생겼고,

target 폴더 안에는 jar 파일이 생성되었다.

🦎 Jenkins에서 빌드 후 배포된 결과물을 Nginx에게 전달하기

젠킨스에서 git clone을 진행하고 Maven 빌드 -> 테스트 -> 배포가 진행된 것을 확인해보았으니 이번엔 배포된 결과물을 Nginx에게 전달해보자.

선행되어야 할 조건은 다음과 같다.

  1. jenkins에서 nginx에 접속하기 위해서는 키를 복사하여 넘겨주는 부분이 필요하다.

  2. jenkins에서 ssh 플러그인 설치

  3. jenkins에서 nginx ssh server 세팅

  4. item 구성 수정

먼저 jenkins에서 ssh-keygen 명령어로 엔터를 눌러 키를 하나 생성한다.

ssh 폴더 속 파일 목록을 확인해보면 id_rsa, id_rsa.pub, authorized_keys가 있다.

id_rsa은 pricate key 값이고, id_rsa.pub은 public key 값이다.

cat id_rsa.pub

이 public key 값을 복사하여 nginx의authorized_keys에 붙여넣어준다.

설정이 끝나면 jenkins에서 nginx로 정상적으로 ssh 접속이 되는 지 테스트 한다.

ssh -i ~/.ssh/id_rsa ubuntu@[퍼블릭주소]

정상적으로 접속이 된다.

두 번째로는 jenkins에서 ssh 플러그인을 깔아야된다.

깔았다면 Jenkins 관리에 System으로 들어가 맨 하단으로 내리면 SSH Server를 추가할 수 있는 메뉴가 나온다.

여기서 중요한건 Hostname은 퍼블릭 주소로, Username은 ubuntu, Remote Directory는 /home/ubuntu 로 설정하고 고급 탭을 눌러 ec2 생성 시 발급받은 Key를 여기에 붙여넣어준다.

Test Configuration을 눌렀을 때 Success가 뜨면 잘 설정이 된 것이다.

다음으로 만들었던 testNginx 아이템으로 돌아가 수정해준다.

깃허브에서 push 될 때마다 가져올 수 있도록 Poll SCM을 * * * * *로 적어주어 변경이 일어났을 때 1분 마다 가져오도록 설정했다.

맨 하단으로 내리면 빌드 후 조치가 있다.

여기서 Send build artifacts over SSH를 클릭해주고 방금 만든 ssh Server를 선택해주고

Transfer Set에는 Source files에 target/*.jar, Remove prefix에 target, Remote directory에 app을 적어주었다.

저장 후 지금 빌드하여 실행하면 nginx의 /home/ubuntu/app 폴더에 jar파일이 생성된 것을 확인할 수 있다.

🦥 위에서 작성한 nginx 설정 사항 적용해보기

만들어진 jar 파일을 아래 명령어로 실행해보자.

이 때 --spring.profiles.active=set1 옵션이 있기 때문에 8081 포트로 동작하게 된다.

java -jar /home/ubuntu/app/backend-0.0.1-SNAPSHOT.jar --spring.config.location=file:/home/ubuntu/app/config/prod-application.yaml --spring.profiles.active=set1

nginx를 재시작을 해준 다음에

sudo service nginx restart

위에서 작성한 프로파일 요청 엔드포인트를 호출해보자.

curl -s localhost/profile

nginx를 통해 성공적으로 스프링의 프로파일 확인이 가능하다.

여기서 의문이 들 수 있다.

왜 application-sub.yml을 미리 작성하고, 빌드 후 배포가 진행되는데 nginx 서버에서 ~/app/config에 위치한 prod-application.yaml에 sub 프로파일을 추가해주는 이유가 무엇일까?

기본적으로 Spring Boot는 다음 순서로 구성 파일을 찾아 로드한다:

  1. classpath의 /config 패키지 내부

  2. classpath의 루트 디렉토리

  3. classpath의 /config 패키지 외부에 지정된 경로

이 경우 application.yml과 application-sub.yml은 JAR 파일 내부에 포함되어 있기 때문에 classpath에 있는 것이다.

그러나 명령어에서 --spring.config.location으로 외부 경로를 지정했기 때문에, Spring Boot는 해당 경로의 구성 파일을 먼저 찾아 로드하게 된다.

따라서 prod-application.yaml 파일에 데이터베이스 연결 정보 등의 중요 정보를 포함시켜야 한다.

만약 prod-application.yaml 파일에 데이터베이스 연결 정보가 누락되어 있다면, 애플리케이션은 데이터베이스에 접근할 수 없게 된다.

그 결과 데이터베이스 관련 작업을 수행할 때 연결 정보가 없어서 에러가 발생하게 된다.

따라서 prod-application.yaml 파일에는 반드시 데이터베이스 연결 정보와 같은 중요한 구성 정보를 포함시켜야 한다.

🐬 재가동 및 스위칭 쉘 스크립트 작성

nginx로 요청을 보내면 스프링 서버로 가는 것을 확인하였다.

쉘 스크립트를 통해 스프링 서버를 재가동하고, 이를 nginx가 가리키도록 하자.

이 과정에서 작성할 쉘 스크립트는 2개이다.

재가동 쉘 스크립트

재가동 쉘 스크립트는 nginx가 가리키지 않는 서버의 port를 확인 후, 재가동하는 스크립트이다.

~/app/ 디렉터리 안에 vi 에디터로 deploy.sh 파일을 만들고 아래와 같이 작성하자.

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

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 "> $IDLE_PROFILE 배포"
sudo fuser -k -n tcp $IDLE_PORT
sudo nohup java -jar /home/ubuntu/app/backend-0.0.1-SNAPSHOT.jar --spring.config.location=file:/home/ubuntu/app/config/prod-application.yaml --spring.profiles.active=$IDLE_PROFILE &

echo "> $IDLE_PROFILE 10초 후 Health check 시작"
echo "> curl -s http://localhost:$IDLE_PORT/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
    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

echo "> 스위칭을 시도합니다..."
sleep 10

/home/ubuntu/app/switch.sh

흐름은 다음과 같다.

그리고 health check 기능을 수행하려면 프로젝트의 pom.xml에 의존성 추가를 해준다.

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

actuator는 스프링의 상태를 확인할 수 있는 라이브러리로, 설치 후 /actuator/health로 get 요청을 하면 상태를 확인할 수 있다.

또한 프로젝트에 Security가 적용되어 있으므로 Security 설정 파일에 해당 엔드포인트로 접근할 수 있도록 허용해주자.

 .authorizeHttpRequests((requests) -> requests
 	.requestMatchers(HttpMethod.PATCH, "/v1/notices/{noticeId}").hasRole("ADMIN")
	.requestMatchers(HttpMethod.DELETE, "/v1/notices/{noticeId}").hasRole("ADMIN")
    .requestMatchers("/myBalance").hasAnyRole("USER", "ADMIN")
   	.requestMatchers("/v1/notices/register").hasRole("ADMIN")
    .requestMatchers("/v1/user/**", "/v1/file/**", "/v1/notices/{noticeId}", "/v1/comment/**", "/v1/subjectCategory/**", "/v1/place/**", "/v1/talent/**","/v1/profile/get","/profile", "/actuator/health","/health").permitAll())               

전환 스크립트

전환(스위칭) 스크립트는 nginx가 가리키는 스프링 서버를 바꾸는 스크립트이다.

~/app 디렉터리 안에 switch.sh 파일 생성 후 다음과 같이 작성한다.

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

if [ $CURRENT_PROFILE == set1 ]
then
  IDLE_PORT=8082
elif [ $CURRENT_PROFILE == set2 ]
then
  IDLE_PORT=8081
else
  echo "> 일치하는 Profile이 없습니다. Profile:$CURRENT_PROFILE"
  echo "> 8081을 할당합니다."
  IDLE_PORT=8081
fi

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

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

echo "> Nginx Reload"
sudo service nginx reload

흐름은 다음과 같다.

실행

두 쉘 스크립트에 실행 권한을 주는 명령어를 입력한다.

chmod +x deploy.sh
chmod +x switch.sh

그리고 deploy.sh 파일을 실행한다.

성공적으로 set2 프로필로 스프링 서버가 구동되고, nginx가 가르키는 것을 확인할 수 있다.

2대의 스프링 서버 중 nginx가 가리키지 않는 포트로 재가동 하게 된다.

🐠 Jenkins CI/CD 최종 구축

젠킨스를 통해 github의 변화가 있을 때마다 배포, 재가동, 스위칭 작업까지 진행해보자.

testNginx 라는 명으로 만든 아이템 구성을 수정하자.

하단으로 내려보면 빌드 유발 항목이 있는데, github에 변화가 있을 때마다 빌드가 될 수 있도록 Poll SCM를 체크해주고, 스케줄을 * * * * *로 설정해주었다.

이는 github에 변화가 생기면 1분 후 빌드가 실행되게 해주는 작업이다.

맨 하단에 빌드 후 조치로 내려가보자

모두 그대로 두되, Exec command/home/ubuntu/app/deploy.sh > /dev/null 2>&1라고 적어주었다.

Exec command는 ssh 배포 후 실행하는 명령어이다. 여기선 deploy.sh를 실행하라고 명령어를 적어주었다.

> /dev/null 2>&1는 표준 출력과 표준 입력을 버리는 것이다.

이를 붙이지 않으면, 젠킨스가 쉘 스크립트 수행 후 빠져나오지 못하기 때문에 꼭 붙여주자.

Exec command에는 절대 경로로 적어주어야 한다. 상대경로로 적을 시 정상 실행이 안되니 유의하자.

로컬에서 gihub로 프로젝트 소스 코드를 푸시하면 빌드가 시작된다.

콘솔을 확인해보면 SCUCESS가 뜨며 정상적으로 애플리케이션이 구동된다.

출처

Nginx+Spring Boot 무중단 배포 구축하기

프록시(Proxy)와 리버스 프록시(Reverse Proxy)

기존 프로젝트를 무중단 배포로 바꿔보자! (via jenkins)

profile
Velog에 기록 중

0개의 댓글