코프링 CI/CD 무중단 배포 (with jenkins,docker,nginx)

SeungHyuk Shin·2022년 1월 9일
9


우클릭 후 새 탭에서 이미지 열기 클릭하면 큰 이미지로 볼수 있습니다.


도입

기존의 서버는 살려두면서 Blue/Green 배포전략을 사용해 무중단 배포를 구현했습니다. 원래 쿠버네티스를 통해 구현할까 했었는데 관리형 쿠버네티스를 사용하는게 아닌 이상 신경써야 할게 너무도 많은 쿠버네티스의 압도적인 러닝커브에 일단은 추후로 미루어 두었습니다.

현재는 DB는 연결하지 않았지만 다음 글에서는 RDS를 연결하는 것까지 해볼 예정입니다.

간략한 흐름은 다음과 같습니다.

  1. main 브랜치에 변동이 있으면 github가 webhook을 통해 젠킨스를 호출

  2. 호출된 젠킨스는 main 브랜치를 가져와 빌드

  3. 빌드 결과를 슬랙을 통해 전송

  4. 빌드된 jar파일은 배포서버로 던짐

  5. 배포서버에서 받은 jar파일을 기준으로 docker image를 만들고 docker hub에 push

  6. 현재 사용되는 프로필을 확인 후 사용 안되는 프로필 컨테이너를 새 버전으로 배포

  7. nginx 스위치를 통해 새 버전으로 배포된 포트를 바라보게 함


어플리케이션 작성

Spring boot 2.6.3 버전 & Kotlin 1.6 버전 기준

간단한 컨트롤러 작성하기

현재 어플리케이션의 프로필을 알기위해 환경변수에 접근하는 간단한 컨트롤러를 하나 작성합니다.

package com.web.devvy

import lombok.RequiredArgsConstructor
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.core.env.Environment
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import java.util.*


@RestController
@RequiredArgsConstructor
class WebRestController {

    @Autowired
    private lateinit var env: Environment

    @GetMapping("/profile")
    fun getProfile(): String{
        return Arrays.stream(env.activeProfiles).findFirst().orElse("");
    }
}

application.yml 설정하기

이후 application.yml을 다음과 같이 설정해줍니다.

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

라이브러리 추가

추후에 배포 가상머신에서 서버의 health check를 하기 위해서 다음과 같은 라이브러리를 추가해줍니다. actuator는 스프링의 상태를 확인할 수 있는 라이브러리로, 설치 후 /actuator/health로 get 요청을 하면 상태를 확인할 수 있습니다.

health는 여러가지 상태 정보를 조합해서 애플리케이션 상태를 나타냅니다. 예를 들면

  • 장비 디스크 용량
  • DB, Redis, Elasticsearch, kafka 등 의존하는 인프라의 상태
  • 사용자 정의 Health Indicator 상태
implementation ("org.springframework.boot:spring-boot-starter-actuator")

Jenkins 설정

github의 main 브랜치에 변화가 생기면 자동으로 pull 하는 CI를 구축

젠킨스 설치

젠킨스용 ec2서버에 도커를 설치 한 후 공식이미지를 통해 젠킨스를 설치했습니다.

sudo docker run -itd --name jenkins -p 9000:8080 jenkins/jenkins:lts

이후 ec2의 퍼블릭 IPv4주소의 9000포트로 접근하면 젠킨스가 잘 작동되는 것을 확인 할 수 있습니다. 물론 ec2 보안그룹에서 인바운드 규칙에 9000번포트를 열어 주어야 합니다.

초기에 접근하면 아래와 같은 화면이 나오는데 docker exec <CONTAINER_NAME> cat /var/lib/jenkins/secrets/initialAdminPassword을 통해 초기 비밀번호를 확인하고 위 칸에 적어주고 Continue 버튼을 누릅니다.

이후 플러그인 설치화면이 나오면 install suggested plugin을 클릭하고 설치가 되면 첫 admin을 등록해야 합니다.

어드민에 관한 정보는 꼭 따로 저장해도록 합시다. 저처럼 어드민 아이디 비밀번호를 까먹어서 젠킨스 환경변수를 다 까보고 싶지 않으면요. 🙃🙃

젠킨스 CI 설정하기

우선 자신의 깃허브로 접속을 한 후 프로필을 클릭해 Settings를 누릅니다.

Developer settings을 클릭합니다.

Personal access tokens에 들어간 후 Generate new token 버튼을 누릅니다. 그리고 아래와 같이 설정한 후 토큰을 발급 받습니다. 발급된 토큰은 해당 페이지를 벗어나면 다시 확인할 방법이 없으니 꼭꼭 복사해 저장해두도록 합시다.

이후에 자신의 깃허브 리포지터리의 Settings 눌러 들어가서 webhook 탭을 클릭하고 Add webhook 버튼을 누릅니다.

Payload URL에 젠킨스가 구동되는 ec2 ip 또는 DNS를 적고 뒤에 github-webhook/을 추가로 적습니다. 그리고 다음과 같이 설정한 후 Update webhook 버튼을 누릅니다.

이제 젠킨스 메인 화면에 가서 새로운 Item을 클릭합니다. 이후 원하는 이름으로 설정한 후 Freestyle project를 누르고 OK를 누릅니다. 그럼 아이템이 만들어지게 될텐데 구성을 클릭합니다.

소스 코드 관리 섹션에 가서 아래 그림처럼 Github 리포지터리 URL을 기입후 Credentials ADD를 클릭해 생성 합니다.

Kind를 Secret text로 설정하고, Secret에 위에서 복사한 토큰을 입력합니다. 그리고 ID에 자신의 ID를 입력한 후 Add 버튼을 눌러 완성합니다.

그리고 빌드 유발 섹션에서 GitHub hook trigger for GITScm polling을 선택하고 저장합니다.

그러면 이제 실제로 push하면 빌드가 이루어지는지 확인해보겠습니다. main브랜치에 push를 하면 빌드가 생성되고 Console Output으로 가보면 확인해 볼 수 있습니다.

잘 작동되는것을 확인했고 이제 배포를 해보도록 하겠습니다.

젠킨스 CD 및 쉘 스크립트 실행 구축

CI를 통해 가져온 서버 코드를 gradle로 실행파일을 만든 후, nginx와 스프링 서버가 있는 ec2에 배포 후, deploy.sh을 실행

우선 제 프로젝트는 gradle로 작성되어있으므로 빌드를 하기 위해선 gradle 플러그인이 필요합니다.

젠킨스 관리 > global tool configuration로 이동 한 후 Gradle 섹션을 찾아 Add Gradle 버튼을 누르고 원하는 gradle 버전을 클릭한 후 gradle version을 선택하고 저장하면 빌드에 필요한 Gradle 설치는 끝입니다.

이후 아이템의 구성 에 들어가 Build 섹션에 Add build step > Invoke Gradle Script를 선택하고 아래 그림과 같이 설정했습니다.

이후 배포서버에 빌드된 jar파일을 던져줘야 하기때문에 Publish Over SSH 플러그인이 필요합니다.

젠킨스 관리 > 플러그인 관리 로 들어가 Publish Over SSH을 설치합니다. 설치한 뒤젠킨스 관리 > 시스템 설정 으로 들어가 Publish over SSH를 아래 사진처럼 구성했습니다.

  • Key는 서버가 구동중인 ec2 인스턴스의 .pem 파일 내용
  • Hostname은 서버가 구동중인 ec2 인스턴스의 ip
  • Username에는 접속하려는 유저와, Remote Directory에는 접속했을 때의 기본 디렉터리를 설정

그리고 아이템의 구성에 돌아가 빌드 후 조치 추가 > Send build artifacts over SSH를 누릅니다.

그리고 아래와 같이 구성했습니다.

> /dev/null 2>&1는 표준 출력과 표준 입력을 버리는 것입니다. 이를 붙이지 않으면, 젠킨스가 쉘 스크립트 수행 후 빠져나오지 못하기 때문에 붙여주었습니다.

한가지 주의할 점은 Exec command에는 절대 경로를 설정해야한다는 점입니다. 만약 쉘 스크립트를 실행한다면 쉘 스크립트 안의 명령어들도 모두 절대 경로여야 합니다.

원래 jenkins내에서 도커 이미지를 빌드해서 Docker Hub로 보내주려고 했기에 Docker 안에 Docker를 띄우는데 며칠동안 고생을 엄청했습니다. 해결방법은 호스트의 Docker Daemon을 Container의 Docker와 연결해주면 Container 안에 Docker를 설치하지 않아도 호스트의 Docker를 빌려 사용할 수 있었습니다.

Slack Notification

Slack으로 알림을 보내는건 매우 간단하기도 하고 이것마저 다 쓰면 글이 너무 길어질것 같아서 링크로 대체 하겠습니다.


Nginx

Nginx 설치

다음 명령어를 통해 배포서버에 Nginx를 설치하고 실행하고 상태를 살펴봅니다.

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

그 후에 웹 브라우저로 ec2 서버에 접속하면 다음과 같이 nginx의 기본 홈페이지 화면을 볼 수 있습니다.

Nginx 설정

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

다음 명령어로 nginx 설정파일을 엽니다.
sudo vim /etc/nginx/nginx.conf

그리고 두줄을 아래 사진처럼 추가합니다.

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

그리고 /etc/nginx/conf.d/ 디렉터리 안에 service-url.inc 파일을 생성하고 아래처럼 작성합니다. 이렇게 설정하면 nginx는 사용자의 요청을 스프링에게 전달하게 됩니다.

set $service_url http://127.0.0.1:8081;

그리고 Nginx를 재시작합니다

sudo service nginx restart

deploy.sh 쉘 스크립트 작성

deploy.sh 쉘 스크립트는 nginx가 가리키지 않는 서버의 port를 확인 하는 젠킨스에서 빌드후 실핼되는 스크립트입니다.

#!/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

CONTAINER_ID=$(docker container ls -f "name=${IDLE_PROFILE}" -q)

sudo docker stop ${IDLE_PROFILE}
sudo docker rm ${IDLE_PROFILE}
TAG_ID = $(docker images | sort -r -k2  -h | | awk 'NR > 1 {if ($1 == "아이디/devyy") {print $2 += .01; exit} else {print 0.01; exit}}')


echo "> 도버 build 실행 : docker build --build-arg DEPENDENCY=build/dependency --build-arg IDLE_PROFILE=${IDLE_PROFILE} -t 아이디/devyy:${TAG_ID} ."
sudo docker build --build-arg DEPENDENCY=build/dependency --build-arg IDLE_PROFILE=${IDLE_PROFILE} -t 아이디/devyy:${TAG_ID} .


echo "> $IDLE_PROFILE 배포"

sudo docker login -u 아이디 -p 비밀번호

sudo docker push 아이디/devyy:${TAG_ID}

#tag가 latest인 image를 최신 버전을 통해 생성
sudo docker tag 아이디/devyy:${TAG_ID} 아이디/devyy:latest

#latest를 docker hub에 push
sudo docker push 아이디/devyy:latest

echo "> 도커 run 실행 :  sudo docker run --name $IDLE_PROFILE -d --rm -p $IDLE_PORT:${IDLE_PORT} 아이디/devyy  "
sudo docker run --name $IDLE_PROFILE -d --rm -p $IDLE_PORT:${IDLE_PORT} 아이디/devyy

#버전 관리에 문제가 있어 latest를 삭제
sudo docker rmi 아이디/devyy:latest

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
                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

sudo sh /home/ec2-user/app/switch.sh

위 스크립트의 흐름은 다음과 같습니다.

  1. 현재 nginx가 가리키는 스프링의 profile을 확인하고, 가리키지 않는 포트를 IDLE_PORT로 지정합니다.

  2. sudo docker stop ${IDLE_PROFILE} , sudo docker rm ${IDLE_PROFILE}로 IDLE_PROFILE로 구동중인 스프링 서버가 있으면 중단합니다.

  3. 동적으로 도커 이미지 태그를 만들어 젠킨스 서버로 부터 받은 jar 파일을 이미지 빌드합니다.

  4. 일정시간 후에 10번동안 health check를 합니다. 10번 시도 후에도 실패하면 code 1로 나갑니다. 만약 실패하지 않으면 반복문을 빠져나갑니다.

  5. health check 성공하면 switch.sh 스크립트를 실행합니다.

다음은 배포서버에서 이미지를 만들때 사용되는 간단한 도커파일 입니다.

FROM openjdk:8-jdk-alpine
ARG IDLE_PROFILE
ARG JAR_FILE=*SNAPSHOT.jar
ENV ENV_IDLE_PROFILE=$IDLE_PROFILE
COPY ${JAR_FILE} /app.jar
RUN echo $ENV_IDLE_PROFILE
ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${ENV_IDLE_PROFILE}"]

빌드할때 사용되는 ARG 값은 ENTRYPOINT에서 사용할 수 없어 --build-arg IDLE_PROFILE=${IDLE_PROFILE}로 받은 값을 ENV변수에 다시 할당하는 식으로 우회해서 사용했습니다.

switch.sh 쉘 스크립트 작성

switch.sh는 Nginx가 가리키는 스프링 서버를 바꾸는 스크립트입니다.

#!/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

위 스크립트의 흐름은 다음과 같습니다.

  1. 구동중인 프로필을 확인 후, 새롭게 가리킬 포트를 IDLE_PORT에 설정한다.

  2. /etc/nginx/conf.d/service-url.inc파일에 포트 부분을 IDLE_PORT로 바꾼다.

  3. nginx가 새로 바꾼 설정을 읽도록 리로드한다.

실제로 스크립트 실행해보기

jenkins 통해 실행시키는게 아닌 직접 deploy.sh을 실행해 정상적으로 작동하는지 확인해보겠습니다.

docker hub 로그인과 관련되서 비밀번호와 관련된 warning이 있긴 했지만 health check까지 정상적으로 작동하고 포트도 바뀌는것을 확인할 수 있습니다.

http://3.38.39.53/profile 에 접속해보시면 현재 떠있는 프로필이 무엇인지 직접 확인 가능합니다!

마무리

EC2를 프리티어로 사용해서 스토리지와 메모리 관리와 관련해서 시간이 뺏긴 부분이 상당히 많아서 아쉬웠습니다. 실행되고 나서는 굉장히 뿌듯했는데 정리하고 나니 개선할 수 있는 점이 많이 보입니다. 젠킨스가 지원하는 여러가지 플러그인을 통해 해결 할 수 있는 부분은 해결 해보려합니다.

일단 depoly.sh과 switch.sh 자체가 배포서버에서 따로 관리되어야 한다는 점이 문제인 것 같고, jenkins에서 app.jar를 던져주는게 아니라 curl 명령어를 통해 프로필을 미리 보고 docker hub에 배포해서 배포서버에서는 해당 이미지를 pull만 하면 되게 수정 할 수도 있을 것 같은데 jenkins 서버의 스토리지 용량 자체가 얼마 남지 않아서 굉장히 부담스러울것 같습니다.

일단은 이정도로 만족하고 시간 날때마다 개선 시켜나가도록 해야겠습니다.

0개의 댓글