일반적인 배포는 많이 해 봤지만, CI/CD 파이프라인 구축과 무중단 배포는 한 번도 해본 적 없었다. 개인 프로젝트 때 반드시 해 보고 싶었던 것 중 하나였기에, 이번에 직접 구축해 보았다. Jenkins와 Blue/Green 전략을 활용해서 무중단 배포 CI/CD 파이프라인을 구축했고, 문제 발생 시 한 단계 전으로 수동 롤백이 가능하도록 했다. Jenkins 설정이 약간 복잡한 듯 싶었지만 Jenkins가 복잡한 게 아니라 CI/CD와 무중단 배포 구현이 복잡한 거였다.
난 EC2 프리티어 인스턴스와 RDS 프리티어 데이터베이스를 사용했다. 글에서는 기본 배포가 이미 되어 있다고 가정한다. 나는 프리티어를 쓰는 만큼, Jenkins와 스프링 부트 애플리케이션 서버를 단일 프리티어 인스턴스 내에 두었다. 보통은 다른 인스턴스에 구별하여 둔다고 하지만, 단일 인스턴스로 구현하는 것이 Jenkins 학습에 있어 큰 방해요소라고 생각하지 않았고, Jenkins의 기본 틀을 안다면 인스턴스 2개든 3개든 확장하는 건 일도 아니라고 생각했기 때문에 단일 인스턴스로 진행했다.
인프라 구성도는 아래와 같다.
내가 작업 후 dev 브랜치에 push를 하면, GitHub 웹훅 기능으로 8080포트에서 실행중인 Jenkins에게 알림을 보낸다. Jenkins는 웹훅 수신 후 최신 코드를 받아오고, 빌드하고, 새로운 서버를 가동하고, 프록시 방향 전환 후 새로운 서버에 문제가 없으면 기존 서버를 중지하게 된다. dev 브랜치에 push가 일어날 때마다, 자동으로 서버 중단 없이 최신 코드에 기반한 서버로 교체되는 것이다.
Nginx Reverse Proxy는 80번 포트에서 HTTP 요청을 1순위로 캐치하며, Nginx 설정 파일에 기재되어 있는 서버로(Blue/Green) 트래픽을 보낸다. Nginx 설정 파일은 Jenkins에 의해 수정되며 이것을 통해 프록시 방향이 전환된다. 간혹 착각하는 수가 있는데, 사용자 트래픽은 Jenkins를 거치지 않고 사용자 요청 -> Nginx(80) -> 애플리케이션(8081/8082) 흐름으로 전달된다. Jenkins는 사용자 트래픽을 직접 다루지 않는다. 단지 내부에서 Blue/Green 서버를 관리하고, Nginx 설정 파일을 수정할 뿐이다.
내가 마치 Jenkins가 이런 중요한 역할들을 전부 도맡아 주는 것 처럼 써놨는데, 틀린 말은 아니지만 정확히는 내가 직접 작성한 deploy.sh 배포 스크립트를 Jenkins가 실행하는 것 뿐이다. Jenkins는 배포 파이프라인의 "관리"를 맡는다. 자세한 건 아래에서 설명한다.
브랜치는 본인이 선택하면 된다. 난 추후 개발, 배포서버 모두 배포할 것이고 현재는 개발 단계기 때문에 dev브랜치를 대상으로 했다.
구축 절차
전체적인 구축 절차는 아래와 같다.
1. Jenkins 설치 및 기본 환경 설정
2. GitHub 웹훅 설정 ( push 알림 )
3. Blue-Green 배포 환경 구성 ( Nginx, blue/green 환경 구축 )
4. 배포 스크립트 작성 ( deploy.sh 작성 -> 새 서버 가동, 기존 서버 종료, health check, 프록시 전환 등 메인 로직이 여기 다 들어가 있음 )
5. Jenkins 파이프라인 구성 (Jenkinsfile)
파이프라인 구축 전에 미리 큰 흐름을 한번 이해하고 나니, "이러면 Jenkins를 왜 쓰지?" 라는 의문이 들었다. 왜냐하면 저 1,2,3,4,5 번 중에 Jenkins가 해 주는 거라곤 5번 하나밖에 없다. 5번이 뭐 하는 거냐면 Jenkinsfile 작성하는 건데, [깃에서 코드 가져오기, 빌드하기, 내가 짠 deploy.sh 실행하기] 이다. 그냥 저걸 순서대로 하라고 명시하는 것 뿐이다. 순서만 명시한다. 심지어 코드 가져오는 거랑 빌드하는 것도 Jenkinsfile에 내가 직접 명령어 작성해야 한다.
저 5개 중에서 Jenkins가 자동으로 해 주는게 단 한 개도 없다. 정말로 단 한 개도 없다. 당연히 의문이 든다. "Jenkins 왜 쓰지?"
나는 CI/CD를 구축하려면 Jenkins를 사용해야 한다 라는 말을 많이 들었어서, Jenkins = CI/CD 인 줄 알았었다. 그러나 이번에 직접 공부해보며 그게 아니라는 걸 깨달았다. Jenkins는 그냥 CI/CD 파이프라인을 관리하기 쉽게 도와주는 프레임워크 같은 존재이다. 모든 설정은 내가 직접 하고, 구축 및 환경 설정 전부 내가 직접 한다.
솔직히 Jenkins 안 써도 된다. 쉘 스크립트로 똑같이 구축할 수 있다. 그러나 Jenkins는 CI/CD를 관리하기 위한 표준화된 "틀"을 제공해 줌으로써, 개발자 본인과 코드를 보는 다른 이로 하여금 배포 파이프라인의 이해와 유지보수를 쉽게 한다. 배포 파이프라인이 복잡할 수록, 규모가 커질 수록 Jenkins의 사용은 장기적 관점에서 많은 이득을 가져다 준다.
리눅스 환경에 익숙한 나에겐, 솔직히 Jenkins 안 썼으면 훨씬 빨리 구축했을 것 같다. 나의 개인 프로젝트는 소규모이고 실 사용자도 없기 때문에 현재 상황에서 Jenkins의 도입은 투자 대비 효용이 너무나 낮은 일이었다. 그러나 지금은 공부와 포트폴리오가 목적이니 꼭 해봐야 할 일이긴 했다.
1. Jenkins 설치 및 기본 환경 설정
# Jenkins 저장소 키 추가
wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add -
# Jenkins 저장소 추가
sudo sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'
# 패키지 업데이트 및 Jenkins 설치
sudo apt update
sudo apt install jenkins
# JDK 설치 (이미 있다면 생략)
sudo apt install openjdk-17-jdk
# Jenkins 서비스 시작
sudo systemctl start jenkins
sudo systemctl enable jenkins
가장 먼저, EC2 환경에 Jenkins를 설치하고 시작해 준다.
그리고 http://{내IP}:8080 포트로 접속하면 Jenkins 웹 인터페이스가 뜬다.
그러면서 초기 비밀번호를 입력하라고 나오는데, " /var/lib/jenkins/secrets/initialAdminPassword" 경로에 초기 비밀번호가 존재한다. 이 파일의 내용을 입력하면 된다.
또한 플러그인 종류가 2가지 있는데, suggest 라고 적힌 플러그인을 설치하면 된다. 설치는 몇분 정도 걸리고 클릭 후 기다리기만 하면 필수 플러그인들이 자동으로 설치된다.
이후 Jenkins 웹 인터페이스에서 Jenkins 관리 -> System 에 들어간다. 그리고 GitHub 서버 설정, 인증(PAT 토큰 등록), Job 설정 등을 해주면 된다.
2. GitHub 웹훅 설정
GitHub 프로젝트 -> Settings -> Webhooks -> Add webhook 경로로 접속한다.
그럼 위와 같은 창이 뜬다. payload URL은 "http://{EC2 IP}:8080/github-webhook/" 을 입력한다. 이 때, 절대로 맨 마지막 "/" 를 빼먹으면 안 된다. 그러면 젠킨스가 잘못된 URL로 인식하여 302 상태코드와 함께 github-webhook/ ("/"를 추가) 경로로 리다이렉트 시키며, 이 과정에서 POST 데이터가 소실되어 웹훅이 정상적으로 동작하지 않는다. 만약 본인이 jenkins 웹 인터페이스에서 특별히 다른 payload URL을 설정했다면 경로를 바꿔야 하지만, 그러지 않았다면 저 값을 입력하면 된다.
Content type은 application/json으로, secret 은 비워놔도 되며 SSL verification은 Enable SSL verification을 선택한다. 본인의 서비스에 SSL 인증서가 없더라도 문제 없이 동작한다. 그리고 단순히 웹훅 알림만 필요한 것이니 Just the push event를 선택하고 Add webhook 을 눌러 웹훅을 만들자.
3. Blue-Green 배포 환경 구성 (Nginx)
Blue-Green 무중단 배포에 있어서 Nginx의 존재는 매우 중요하다. 현재 가동중인 서버에 따라 사용자 트래픽을 전환하는 리버스 프록시 역할을 수행하기 때문이다.
# Nginx 설치
sudo apt install nginx
# Nginx 설정 파일 생성
sudo nano /etc/nginx/sites-available/suncar
Nginx를 설치하고 Nginx 설정 파일을 생성한다.
설정파일 이름은 자유롭게 해도 좋다. 난 내 프로젝트 이름인 suncar로 했다.
# /etc/nginx/sites-available/suncar
upstream suncar {
server 127.0.0.1:8081; # Blue 환경 (초기 설정)
}
server {
listen 80; # Nginx가 80포트 요청 수신하도록 설정
server_name _; # 모든 도메인 이름에 대해 이 server 블록 적용
location / { # / (루트) 경로로 들어오는 모든 요청에 대한 처리 방법 정의
proxy_pass http://suncar; # 들어오는 요청을 upstream suncar 그룹으로 전달
proxy_set_header Host $host; # 원래 요청의 호스트명 ( host 및 아래 설정들은 클라이언트 정보를 담는 설정)
proxy_set_header X-Real-IP $remote_addr; # 클라이언트 실제 IP 주소
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 클라이언트와 중간 프록시의 ip 주소
들
proxy_set_header X-Forwarded-Proto $scheme; # 원래 요청의 프로토콜 (http/https)
}
}
upstream suncar
-> suncar 라는 그룹을 만든다. 그 그룹은 127.0.0.1:8081 URL을 의미한다. 즉 Blue 서버이다.
server
-> Nginx 설정이다. 주석에 적힌 것과 같이 80번 포트 모든 도메인으로 들어오는 트래픽을 "suncar" 그룹으로 보내도록 한다.
즉 EC2 인스턴스의 80번 포트로 들어오는 트래픽을 -> Blue 서버(8081)로 보낸다. 이다.
만약 여기서 server 값을 127.0.0.1:8082 로 바꾸면 어떻게 될까? -> Green 서버(8082)로 보내지게 된다.
이것은 아래 deploy.sh 스크립트를 작성할 때 사용되는데, 서버가 교체될 때 마다 이 파일의 포트번호만을 교체해서 (8081 <-> 8082) 프록시 방향을 수정하게 된다.
# 심볼릭 링크 생성 및 Nginx 재시작
sudo ln -s /etc/nginx/sites-available/suncar /etc/nginx/sites-enabled/
sudo nginx -t # 설정 검증
sudo systemctl restart nginx
설정을 저장했으면 심볼릭 링크를 생성하고 설정 검증 후 Nginx를 재시작한다.
왜 심볼릭 링크를 생성하느냐? Nginx는 sites-enabled/ 디렉터리 하위에 있는 설정을 실제로 "적용"한다.
우리가 방금 설정 파일을 작성한 경로인 sites-available/ 디렉터리는 설정을 "보관"하는 곳이다.
그래서 우리는 sites-available 디렉터리에 먼저 설정을 "보관" 한 후에,
sites-enabled 디렉터리에서 sites-available 디렉터리에 미리 만들어 둔 설정을 가리키는 심볼릭 링크 파일을 생성하면?
심볼릭 링크의 생성/삭제를 통해, 설정을 자유롭게 적용하거나 적용하지 않을 수 있다.
이것이 Nginx가 설정 파일을 관리하는 방식이다.
아 그리고 아마 Nginx 처음 설치했다면 sites-enabled에 "default" 라는 설정 파일이 있을 텐데, 이거 도메인 이름이 _ 로써 우리가 작성한 파일과 충돌이 날 수 있다. 어차피 default는 available에 존재하기 때문에, 과감히 enabled에서 삭제하면 된다.
4. 배포 스크립트 작성(deploy.sh, 배포 메인 로직)
이제 디렉터리 구조와 배포 스크립트를 작성한다. deploy.sh 스크립트 파일은 배포에 관한 로직의 대부분을 갖고 있는 중요한 파일이다. 리눅스 환경에서 일반적으로 사용자 애플리케이션은 /opt/applications 경로에 두는 관행이 있기 때문에, 우리도 그것을 따른다.
mkdir -p /opt/applications/suncar/blue
mkdir -p /opt/applications/suncar/green
mkdir -p /opt/applications/suncar/scripts
디렉터리 구조를 생성한다. /opt/applications 하위에 프로젝트명과 blue, green 디렉터리를 생성한다.
추가로 배포나 환경변수 설정 같은 sh 파일을 모아놓을 scripts 디렉터리도 생성해 준다.
# /opt/applications/suncar/scripts/set-env-dev.sh
export DB_URL=jdbc:mysql://....
export DB_USERNAME=DB 계정명
export DB_PASSWORD=패스워드
export JWT_SECRET=JWT 시크릿 키
export JWT_EXPIRATION=만료시간
# 이외 필요한 환경변수들 ...
환경변수를 세팅하는 set-env-dev.sh 쉘 스크립트이다. 본인의 애플리케이션에서 필요한 환경변수들을 여기에 적어주면 된다.
단 주의할 점은, DB_URL 작성할 때 mysql의 경우 반드시 "jdbc:mysql://" 접두사를 붙여야 한다는 것이다.
인텔리제이나 DataGrip, DBeaver 같은 도구들은 DB URL을 읽을 때 알아서 jdbc 접두사를 붙이게 된다. 그래서 우리가 평소에 접두사를 빼도 문제가 없는 것이다. 그러나 EC2 인스턴스 환경에서 빌드된 jar 파일을 직접 실행할 땐, 접두사를 처리해줄 도구가 없다. 따라서 직접 붙여줘야 DB URL을 정상적으로 인식하고 접속할 수 있게 된다.
만약 mysql이 아니라면 다른 접두사를 붙여야 하니 그것은 따로 찾아보도록 하자.
# /opt/applications/suncar/scripts/deploy.sh
#!/bin/bash
echo "########################################"
echo "배포 스크립트 시작(deploy.sh)"
# 현재 실행 중인 서버 확인 (blue: 8081, green: 8082)
CURRENT_PORT=$(curl -s http://localhost/serverProfile)
# 타겟 포트 결정 (현재 사용 중이지 않은 포트)
if [ ${CURRENT_PORT} == "8081" ]; then
TARGET_PORT=8082
TARGET_ENV="green"
CURRENT_ENV="blue"
else
TARGET_PORT=8081
TARGET_ENV="blue"
CURRENT_ENV="green"
fi
echo "현재 서버 -> ${CURRENT_ENV}: ${CURRENT_PORT}"
echo "교체 예정 서버 -> ${TARGET_ENV}: ${TARGET_PORT}"
# env, jar 파일 경로
ENV_FILE="/opt/applications/suncar/scripts/set-env-dev.sh"
JAR_FILE="/opt/applications/suncar/${TARGET_ENV}/app.jar"
# 환경 변수 로드
source ${ENV_FILE}
# 서버 교체
echo "########################################"
echo "서버 교체 시작"
echo "새로운 서버 시작하는 중..."
nohup java -Dspring.datasource.url="$DB_URL" \
-Dspring.datasource.username="$DB_USERNAME" \
-Dspring.datasource.password="$DB_PASSWORD" \
-Djwt.secret="$JWT_SECRET" \
-Djwt.expiration="$JWT_EXPIRATION" \
-Dspring.profiles.active=dev \
-Dserver.port=${TARGET_PORT} \
-Dserver.address=0.0.0.0 \
-jar ${JAR_FILE} \
> /opt/applications/suncar/${TARGET_ENV}/application.log 2>&1 &
echo "새로운 서버 PID: $!"
echo $! > /opt/applications/suncar/${TARGET_ENV}/application.pid
# 새 서버가 완전히 구동될 때 까지 대기
echo "새 서버 상태 확인"
sleep 10
# 새 서버 상태 확인
for RETRY_COUNT in {1..10}; do
echo "새 서버 상태 확인 중... ${RETRY_COUNT}/10"
RESPONSE=$(curl -s http://localhost:${TARGET_PORT}/actuator/health)
UP_COUNT=$(echo ${RESPONSE} | grep -c "UP")
if [ ${UP_COUNT} -ge 1 ]; then
echo "새 서버가 정상적으로 구동되었습니다."
break
fi
if [ ${RETRY_COUNT} -eq 10 ]; then
echo "새 서버 구동 실패. 배포를 중단합니다."
exit 1
fi
sleep 5
done
# Nginx 프록시 패스 변경
echo "########################################"
echo "Nginx 프록시 변경"
NGINX_CONFIG="/etc/nginx/sites-available/suncar"
sed -i "s/server 127.0.0.1:${CURRENT_PORT};/server 127.0.0.1:${TARGET_PORT};/" ${NGINX_CONFIG}
sudo systemctl reload nginx
echo "Nginx 프록시가 ${CURRENT_ENV}: ${CURRENT_PORT} -> ${TARGET_ENV}: ${TARGET_PORT} 포트로 전환되었습니다."
# 기존 서버 종료
echo "########################################"
echo "기존 서버 종료"
if [ -f "/opt/applications/suncar/${CURRENT_ENV}/application.pid" ]; then
OLD_PID=$(cat /opt/applications/suncar/${CURRENT_ENV}/application.pid)
if ps -p ${OLD_PID} > /dev/null; then
echo "기존 서버 ${CURRENT_ENV}: ${CURRENT_PORT}, PID: ${OLD_PID} 종료 중..."
kill ${OLD_PID}
for i in {1..6}; do
if ! ps -p ${OLD_PID} > /dev/null; then
echo "기존 서버가 정상적으로 종료되었습니다."
break
fi
echo "기존 서버 종료 대기 중... ${i}/6"
sleep 5
done
# 30초 후에도 실행 중이면 강제 종료
if ps -p ${OLD_PID} > /dev/null; then
echo "기존 서버가 30초간 종료되지 않았습니다. 강제 종료합니다..."
kill -9 ${OLD_PID}
fi
fi
fi
echo "########################################"
echo " 배포가 완료되었습니다."
echo "########################################"
음.. 아주 조금 복잡해 보이긴 하지만, 별로 복잡하진 않다.
전체적인 흐름은 이렇다.
1. 현재 실행 중인 서버 확인(사용중인 포트번호 확인하는 api 엔드포인트 호출, 구현해야 함)
2. 타겟 포트 결정 ( 8081 사용중이면 8082, 8082 사용중이면 8081 )
3. 환경변수 로드 (set-env-dev.sh)
4. 새로운 서버 시작 및 로그, PID 저장 ( 아직 기존 서버 실행 중 )
5. 새로운 서버 health check ( health check api 엔드포인트도 구현해야 함, 아직 기존 서버 실행 중 )
6. Nginx 프록시 방향 변경 ( 위 Nginx 설정 파일에서 봤던 포트번호를 변경한다, 아직 기존 서버 실행 중 )
7. 기존 서버 종료, 배포 종료
요약하면 새로운 서버를 띄우고 트래픽을 완전히 전환한 후에 기존 서버를 내리는 것이다.
이 deploy.sh 파일은 Jenkins가 배포 파이프라인 진행 중에 실행하게 된다.
이제 health check 관련 api 엔드포인트를 만들어 보자.
Health Check 관련 api 작성
현재 사용중인 포트 반환 api
// controller/server/ServerController
/**
* 서버레벨 데이터 조회를 담당하는 컨트롤러 입니다.
*/
@RestController
public class ServerController {
@Autowired
private Environment env;
/**
* 현재 서버가 사용중인 포트번호를 반환합니다.
*
* @return 현재 사용중인 포트번호
*/
@GetMapping("/serverProfile")
public String getServerProfile() {
return env.getProperty("server.port", "8081"); // 8081 : 기본값(Blue)
}
}
스프링 부트 애플리케이션에 직접 작성하면 된다.
이 api는 서버가 현재 사용중인 포트번호를 String 형태로 반환한다.
Actuator 의존성 추가 및 설정
actuator는 health check와 관련한 api를 자동으로 추가해주는 의존성이다.
// build.gradle
dependencies {
// 생략 ..
// Actuator 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
// application.properties
# actuator 설정 추가
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=always
위와 같이 actuator 의존성과 설정을 추가해준다. 이러면 엔드포인트 2개가 추가되는데,
/actuator/health
-> 서버 health check, 애플리케이션 상태 확인
-> show-details=always 설정 때문에 DB, 디스크 등 상세 정보가 리턴됨
/actuator/info
-> 기본값은 비어있지만, 사용자가 리턴값을 커스텀할 수 있음
우리는 /actuator/health 엔드포인트를 deploy.sh 스크립트에서 사용하게 된다.
5. Jenkins 파이프라인 구성
Jenkinsfile을 작성한다. Jenkins는 이 파일의 내용을 토대로 배포 파이프라인을 진행하게 된다. 즉, 전체적인 배포의 흐름이 이 파일에 전부 정의되어 있다.
# /opt/applications/suncar/suncar-backend/Jenkinsfile
pipeline {
agent any
# 애플리케이션 빌드에 JDK17을 사용한다
tools {
jdk 'JDK17'
}
# push 웹훅이 수신되면 동작한다
triggers {
githubPush()
}
stages {
# Jenkins가 애플리케이션 코드를, 최신 git repository와 동기화한다
stage('Checkout') {
steps {
checkout scm
}
}
# 애플리케이션 빌드 (테스트 생략, 프리티어에서 테스트 하면 서버 터짐)
stage('Build') {
steps {
sh '''
./gradlew clean build -x test
'''
}
}
# 타겟 환경(blue/green) 가져오기
stage('Determine Target Environment') {
steps {
script {
// 현재 활성화된 환경 확인
def currentPort = sh(
script: "curl -s http://localhost/serverProfile || echo '8081'",
returnStdout: true
).trim()
// 타겟 환경 설정
if (currentPort == "8081") {
env.TARGET_ENV = "green"
env.TARGET_PORT = "8082"
}
else {
env.TARGET_ENV = "blue"
env.TARGET_PORT = "8081"
}
}
}
}
# jar 파일을 저장할 로컬 디렉터리 생성
stage('Prepare Target Directory') {
steps {
sh """
# 대상 디렉토리 생성(없는 경우)
mkdir -p /opt/applications/suncar/${env.TARGET_ENV}
"""
}
}
# jar 파일 복사
stage('Copy JAR') {
steps {
sh """
# 빌드된 jar 파일을 로컬 디렉터리로 복사
cp \$(find ${WORKSPACE}/build/libs -name "*.jar" -not -name "*-plain.jar") /opt/applications/suncar/${env.TARGET_ENV}/app.jar
"""
}
}
# 메인 배포 로직 실행
# 위에서 우리가 작성한 deploy.sh 파일
stage('Deploy') {
steps {
sh '''
sudo /opt/applications/suncar/scripts/deploy.sh
'''
}
}
}
post {
success {
echo '파이프라인이 성공적으로 완료되었습니다.'
}
failure {
echo '파이프라인 실행 중 오류가 발생했습니다.'
}
}
}
Jenkinsfile은 언제나 "git repository 프로젝트 루트 디렉터리"에 위치해야 한다. 즉 EC2 환경엔 없어도 되고, 그저 git repository의 프로젝트 루트 디렉터리에 존재하기만 하면 된다. Jenkinsfile을 작성 후 repository에 반드시 push 해 주자.
흐름은 아래와 같다.
1. 애플리케이션 코드를 최신 repository와 동기화(내 경우엔 dev 브랜치)
2. 빌드
3. 빌드된 jar 파일을 로컬 디렉터리로 복사 (복사된 경로에 있는 jar파일을 deploy.sh가 참조함)
4. 메인 배포 로직 실행 (deploy.sh 실행)
이제 Jenkins는 dev 브랜치에 코드가 push 될 때 마다 웹훅을 수신하여 이 배포 파이프라인을 진행하게 된다.
추가로 jenkins 사용자에게 권한을 줘야 한다. jenkins 사용자는 Jenkins가 사용하는 계정이다.
# Jenkins 사용자에게 sudo 권한 부여
echo "jenkins ALL=(ALL) NOPASSWD: /opt/applications/suncar/scripts/deploy.sh" | sudo tee /etc/sudoers.d/jenkins
sudo chmod 440 /etc/sudoers.d/jenkins
# 스크립트 실행 권한 부여
sudo chmod +x /opt/applications/suncar/scripts/deploy.sh
이렇게 jenkins 사용자가 deploy.sh 를 실행할 때 sudo 권한을 사용할 수 있게 해 준다.
/etc/sudoers.d/jenkins 파일은 jenkins 사용자의 권한을 저장해놓은 파일인데, 보안 상 440으로 설정한다.
마지막으로 deploy.sh 파일에 실행권한을 추가해 주면 된다.
이제 Jenkins 웹 인터페이스로 접속한다. 아래와 같은 설정을 해 주자.
1. Jenkins 웹 인터페이스 -> New Item -> Pipeline
2. Pipeline script from SCM 선택
3. SCM: Git
4. Repository URL: GitHub 저장소 URL ( 맨 뒤에 .git 붙여야 함!! )
5. Branch: */dev
6. Script Path: Jenkinsfile ( Jenkinsfile 경로 바꿔도 되긴 하는데 굳이 바꿀필욘 없다 )
모든 설정이 끝났다. 이제 제대로 동작하는지 테스트 할 일만 남았다.
6. 테스트
우선 웹훅 연동 테스트를 하기 전에, 빌드 자체가 제대로 동작하는지를 테스트 해야 한다. Jenkins 웹 인터페이스에서 테스트할 수 있다.
아까 만들어 두었던 pipeline 으로 들어오면, 이런 화면이 나온다. 좌측 메뉴에서 "지금 빌드" 버튼을 클릭해 보자.
시작된 빌드를 클릭하고 좌측의 Console Output 을 누르면 이렇게 실시간으로 빌드되는 것을 확인할 수 있다.
오류가 있다면 여기 콘솔에서 확인할 수 있으니, 찾아서 디버깅 해보자.
나는 이미 자잘한 오류들을 디버깅 해 놓은 상태라(파일경로, 명령어 오타 등) 바로 성공할 수 있었다.
이게 성공한다면 빌드와 배포 자체는 완전히 성공했다고 보면 된다. 이제 웹훅과 연동되는지 테스트 해보면 된다.
어떤 방식으로든 dev에 push한다. 정상적으로 웹훅이 수신되었다면, 마찬가지로 Jenkins 웹 인터페이스 콘솔로 배포 과정을 확인할 수 있다. 만약 진행되지 않는다면 아래 몇 가지를 확인해 보자. 우선 GitHub 웹훅부터 확인한다.
프로젝트 -> settings -> Webhooks 로 접근하면 최근 웹훅 전송 결과를 확인할 수 있다. 실패했다면 실패 원인과 상태코드 등을 확인할 수 있다. 위는 전부 실패한 웹훅들이다.
먼저 확인해 볼 것은 아래와 같다.
- payload url이 정확한지
- 포트번호가 맞는지(8080인지)
- payload url 마지막에 "/" 를 붙였는지(위에서도 말했다)
- content-type이 application/json 인지
이 정도가 될 수 있겠다.
그런데 만약 오류가 403 Forbidden 이라면? 아마 스프링 시큐리티 문제일 가능성이 높다.
스프링 부트 SecurifyConfig 클래스를 확인해 보자.
/**
* Spring Security 설정 클래스입니다.
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtRequestFilter jwtRequestFilter;
/** Security Filter Chain 설정 */
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CORS 설정
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(requests -> requests
// 인증 없이 접근 가능한 경로 설정
.requestMatchers(
"/docs/**",
"/api-docs/**",
"/swagger-ui/**",
"/auth/**",
"/cars/**",
"/serverProfile", // 추가
"/actuator/**", // 추가
"/github-webhook" // 추가
).permitAll()
// 그 외 모든 요청은 인증 필요
.anyRequest().authenticated()
)
// CSRF 보호 비활성화 ( RESTAPI에서는 일반적으로 비활성화 )
.csrf(csrf -> csrf.disable())
// 세션 관리 정책을 STATELESS로 설정 (JWT 사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
이렇게 시큐리티 필터 체인에 health check 관련 엔드포인트들과 웹훅 엔드포인트를 추가해 준다.
근데 이거 push 했다고 바로 403 오류가 해결되지 않는다!! 왜냐?
지금 돌고 있는 서버는 이 설정 적용이 안 되어 있기 때문이다.
이 설정을 적용하기 위해선, Jenkins 웹 인터페이스에서 아까 테스트 했던 것 처럼 "지금 빌드" 버튼을 눌러서 수동으로 배포를 해 주자. 그러면 설정이 적용되고, 그 다음에 다시 push를 해보면 된다.
다시 push하면 이렇게 웹훅 전송에 성공했다는 표시가 뜬다. Jenkins가 웹훅을 정상적으로 수신했기 때문에, 빌드 및 배포도 잘 진행되었다.
추가: 롤백 스크립트
Blue/Green 무중단 배포 전략의 장점 중 하나는, 현재 서버에 문제가 생겼을 시 이전 서버로 롤백이 용이하다는 것이다.
그것은 임계값을 정해두고 그걸 넘을 시 자동으로 롤백되게 할 수도 있겠고, 미리 롤백 스크립트를 작성해 두고 서버에 문제가 생겼다고 판단될 때 수동으로 롤백할 수도 있다. 나는 수동 롤백 방식을 택했다. 이 방식으로도 충분히 롤백이 가능하고 효용이 있다.
나는 blue와 green 디렉터리에, 빌드된 app.jar 파일을 각기 저장하고 있다. 이 중 하나는 현재 실행중인 최신 버전(오류가 있다고 가정)이고 하나는 이전 버전(오류가 없다고 가정)일 것이다. 롤백이란 건 무엇인가? 문제가 없는 버전으로 되돌아 가는 것이다. 현재 버전이 문제가 있으니, 문제없는 이전 버전의 서버를 실행해 트래픽을 거기로 돌리면 되는 것이다.
근데 롤백 스크립트를 따로 작성할 필요가 없다.
왜냐하면, 기존 deploy.sh 스크립트가 롤백 스크립트 그 자체이기 때문이다. 아래는 롤백 스크립트다.
# /opt/applications/suncar/scripts/manual-rollback.sh
#!/bin/bash
echo "########################################"
echo "수동 롤백 시작(manual-rollback.sh)"
# 현재 실행 중인 서버 확인 (blue: 8081, green: 8082)
CURRENT_PORT=$(curl -s http://localhost/serverProfile)
# 타겟 포트 결정 (현재 사용 중이지 않은 포트)
if [ ${CURRENT_PORT} == "8081" ]; then
TARGET_PORT=8082
TARGET_ENV="green"
CURRENT_ENV="blue"
else
TARGET_PORT=8081
TARGET_ENV="blue"
CURRENT_ENV="green"
fi
echo "현재 서버 -> ${CURRENT_ENV}: ${CURRENT_PORT}"
echo "롤백 예정 서버 -> ${TARGET_ENV}: ${TARGET_PORT}"
# env, jar 파일 경로
ENV_FILE="/opt/applications/suncar/scripts/set-env-dev.sh"
JAR_FILE="/opt/applications/suncar/${TARGET_ENV}/app.jar"
# 환경 변수 로드
source ${ENV_FILE}
# 서버 교체
echo "########################################"
echo "롤백 서버 시작하는 중..."
nohup java -Dspring.datasource.url="$DB_URL" \
-Dspring.datasource.username="$DB_USERNAME" \
-Dspring.datasource.password="$DB_PASSWORD" \
-Djwt.secret="$JWT_SECRET" \
-Djwt.expiration="$JWT_EXPIRATION" \
-Dspring.profiles.active=dev \
-Dserver.port=${TARGET_PORT} \
-Dserver.address=0.0.0.0 \
-jar ${JAR_FILE} \
> /opt/applications/suncar/${TARGET_ENV}/application.log 2>&1 &
echo "롤백 서버 PID: $!"
echo $! > /opt/applications/suncar/${TARGET_ENV}/application.pid
# 새 서버가 완전히 구동될 때 까지 대기
echo "롤백 서버 상태 확인"
sleep 10
# 새 서버 상태 확인
for RETRY_COUNT in {1..10}; do
echo "롤백 서버 상태 확인 중... ${RETRY_COUNT}/10"
RESPONSE=$(curl -s http://localhost:${TARGET_PORT}/actuator/health)
UP_COUNT=$(echo ${RESPONSE} | grep -c "UP")
if [ ${UP_COUNT} -ge 1 ]; then
echo "롤백 서버가 정상적으로 구동되었습니다."
break
fi
if [ ${RETRY_COUNT} -eq 10 ]; then
echo "롤백 서버 구동 실패. 롤백을 중단합니다."
exit 1
fi
sleep 5
done
# Nginx 프록시 패스 변경
echo "########################################"
echo "Nginx 프록시 변경"
NGINX_CONFIG="/etc/nginx/sites-available/suncar"
sed -i "s/server 127.0.0.1:${CURRENT_PORT};/server 127.0.0.1:${TARGET_PORT};/" ${NGINX_CONFIG}
sudo systemctl reload nginx
echo "Nginx 프록시가 ${CURRENT_ENV}: ${CURRENT_PORT} -> ${TARGET_ENV}: ${TARGET_PORT} 포트로 전환되었습니다."
# 기존 서버 종료
echo "########################################"
echo "기존 서버 종료"
if [ -f "/opt/applications/suncar/${CURRENT_ENV}/application.pid" ]; then
OLD_PID=$(cat /opt/applications/suncar/${CURRENT_ENV}/application.pid)
if ps -p ${OLD_PID} > /dev/null; then
echo "기존 서버 ${CURRENT_ENV}: ${CURRENT_PORT}, PID: ${OLD_PID} 종료 중..."
kill ${OLD_PID}
for i in {1..6}; do
if ! ps -p ${OLD_PID} > /dev/null; then
echo "기존 서버가 정상적으로 종료되었습니다."
break
fi
echo "기존 서버 종료 대기 중... ${i}/6"
sleep 5
done
# 30초 후에도 실행 중이면 강제 종료
if ps -p ${OLD_PID} > /dev/null; then
echo "기존 서버가 30초간 종료되지 않았습니다. 강제 종료합니다..."
kill -9 ${OLD_PID}
fi
fi
fi
echo "########################################"
echo "롤백이 완료되었습니다."
echo "########################################"
수동 롤백 스크립트이기 때문에 파일명은 manual-rollback.sh 로 했다.
이 파일은 deploy.sh와 완전히 기능적으로 같다. 그저 주석과 echo부분만 롤백에 맞도록 약간 수정했을 뿐이다.
deploy.sh의 기능은 이랬다.
- 이미 빌드된 최신 서버를(jar파일) 실행한다.
- 프록시 방향을 변경하고 트래픽을 최신 서버로 완전히 전환한다.
- 기존 서버를 내린다.
요는 "이미 빌드된 최신 jar파일"로 교체하는 것이다. 여기서 "빌드" 라는 건 deploy.sh가 하는 일이 아니다. Jenkinsfile이 하는 일이다. 그렇다면 Jenkins를 거치지 않고 deploy.sh를 단독으로 실행하면, 최신 코드는 반영되지 않고 blue 또는 green으로의 교체만 일어나며, 그것은 롤백과 같은 효과를 가진다. 물론 2번 실행하면 다시 최신 버전으로 돌아가겠지만, 한번 롤백 되는 것 만으로도 충분한 가치가 있다.
그러나 deploy.sh 파일을 롤백 용으로도 사용해도 되지만, 보는 이로 하여금 헷갈릴 수 있기 때문에(이게 배포 파일인지 롤백 파일인지) 기능은 그대로 유지하되 파일명, 주석, echo만 바꾼 manual-rollback.sh 파일을 새로 만든 것이다. 이제 언제든 서버 상태가 이상하다면 manual-rollback.sh 파일을 실행하면 된다.
그런데 만약 이전 버전 서버에도 문제가 있다면? 여러 번 롤백해야 한다면? -> 그러면 Git에 있는 과거 코드를 활용하면 되겠다.
=============
썬카 노션
https://lava-move-d1e.notion.site/SunCar-1a754e6b788180f598cdea3bfaff3139?pvs=4
깃허브