Jenkins + CodeDeploy + Nginx를 활용한 스프링 부트 Docker 이미지 무중단 배포

appti·2022년 6월 5일
1

Jenkins + CodeDeploy + Nginx를 활용한 스프링 부트 Docker 이미지 무중단 배포

이전에 작성한 Jenkins + CodeDeploy를 활용한 EC2에 스프링 부트 프로젝트 배포 자동화를 약간 더 개선한 버전입니다.
사용한 프로젝트의 경우 단순한 테스트용으로 작성했기 때문에 도커 컴포즈를 사용하지는 않았습니다.


흐름

과정은 다음과 같습니다.

  1. 개발자가 jenkinswebhook으로 연동된 깃허브 리포지토리 브랜치(main)에 push 합니다.
  2. webhook으로 인해 jenkins가 호출되고, 빌드를 진행합니다.
  3. jenkins가 빌드한 파일을 s3에 전달해 업로드합니다.
  4. jenkinscodedeploy에게 배포를 요청합니다.
  5. codedeploys3에 업로드된 빌드 파일을 가져옵니다.
  6. codedeployec2에 배포를 진행합니다.
    5-1. 배포 중 nginx를 통해 무중단 배포를 진행합니다.

무중단 배포

무중단 배포의 경우 rolling 방식을 사용했습니다.
이를 위해 스프링 부트 애플리케이션을 두 개 실행하고, 이를 profile로 구분했습니다.

무중단 배포의 흐름은 다음과 같습니다.

현재 동작 중인 애플리케이션의 profilereal1/8081, 동작하고 있지 않은 애플리케이션의 profilereal2/8082, nginx의 리버스 프록시는 포트 8081을 가리키고 있다고 가정하겠습니다.

  1. codedeploy에서 배포 할 때 현재 동작하고 있는 애플리케이션을 확인합니다.
    1.1 현재 real1이 동작 중인 것을 확인합니다.
  2. 현재 동작하고 있지 않은 애플리케이션을 삭제하고, 새 버전의 애플리케이션을 배포합니다.
    2.1 동작하고 있지 않은 애플리케이션은 real2이기 때문에 real2 컨테이너 종료 -> 컨테이너 삭제 -> 도커 이미지 삭제순으로 진행합니다.
  3. nginx에서 기존에 사용하고 있는 이전 버전 애플리케이션의 포트를 새롭게 배포한 애플리케이션의 포트로 변경합니다.
    3-1. real2 도커 이미지 생성 -> 도커 컨테이너 실행 이후 nginx가 바라보고 있는 포트를 8081에서 8082로 변경합니다.

클라이언트 입장에서는 다음과 같이 애플리케이션을 사용합니다.

  • real2가 배포되고 실행되는 도중에는 real1/8081로 접속해 기존 서비스를 사용합니다.
  • real2의 배포가 완료되고 nginx의 리버스 프록시가 적용되면 real2/8082로 접속해 새로운 서비스를 사용할 수 있습니다.
  • 결과적으로 클라이언트는 배포 도중에도 서비스를 사용할 수 있습니다.
    (무중단 배포)

구성

배포용 Jenkins 서버와 실제 애플리케이션 서버를 분리하는 걸 권장하지만, 프리 티어로 사용하기 위해 하나의 인스턴스를 사용했습니다.

  • nginx
    • 가장 간단하게 무중단 배포를 구현할 수 있어 선택했습니다.
    • 도커 이미지로 실행하지 않고 ec2에서 직접 설치하는 식으로 진행했습니다.
      • 이는 https로 배포하는 것까지 고려하고 있기 때문입니다.
      • 도커 컨테이너 내부에서 https로 배포한다고 해도 ec2에 설정을 하지 않으면 의미가 없다고 생각했기 때문입니다.
  • 스프링 부트 애플리케이션 도커 컨테이너에서 실행
    • ec2에서 자바 관련 세팅을 생략하고 도커로 애플리케이션 의존성을 관리하고자 했습니다.
    • 배포용 Jenkins 서버와 애플리케이션 전용 서버를 분리할 경우를 고려했습니다.

스프링 부트 프로젝트

스프링 부트 프로젝트를 생성합니다.
간단하게 weblombok만 의존하도록 했습니다.
추가적으로 actuator도 의존할 예정입니다.

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-actuator'

actuator 의존성을 추가합니다.
실행되고 있는 애플리케이션의 상태를 파악할 수 있는 기능으로, 무중단 배포에서 사용할 예정입니다.

jar {
	enabled = false
}

애플리케이션을 배포할 때 plain 버전은 필요없으니 이에 대해 설정해줬습니다.

application.yml

management:
  endpoint:
  endpoints:
    web:
      base-path: /application

---
spring:
  config:
    activate:
      on-profile: real1
server:
  port: 8081
---
spring:
  config:
    activate:
      on-profile: real2
server:
  port: 8082

management의 경우 actuator의 관련 설정입니다.
actuator의 경우 기본적으로 /actuator/health 경로로 실행되고 있는 애플리케이션의 상태를 확인할 수 있습니다.
이를 /application/health로 확인할 수 있도록 변경했습니다.

설정한 대로 /application/health로 요청하면 현재 애플리케이션의 상태를 확인할 수 있습니다.
actuator 관련 설정을 추가하지 않았기 때문에 기본적인 애플리케이션 상태(status)만 확인할 수 있으며, 실행 중이기 때문에 UP이라고 출력되었음을 확인할 수 있습니다.

밑에는 각 profile에 대한 설정입니다.
real1 / real2로 이름 지었으며, 각각 8081 / 8082 포트로 동작합니다.

ProfileController

@RestController
@RequiredArgsConstructor
public class ProfileController {

    private final Environment env;

    @GetMapping("/profile")
    public String getProfile() {
        List<String> profiles = Arrays.asList(env.getActiveProfiles());
        List<String> realProfiles = Arrays.asList("real1", "real2");
        String defaultProfile = profiles.isEmpty() ? "default" : profiles.get(0);

        return profiles.stream()
                .filter(realProfiles::contains)
                .findAny()
                .orElse(defaultProfile);
    }
}

현재 동작하고 있는 profile이 무엇인지 확인하기 위한 컨트롤러입니다.
real1 아니면 real2로 고정되겠지만, 기본 profiledefault를 추가해 배포 후 애플리케이션 실행 상황에서의 예외 처리를 해주었습니다.


AWS

이전에 작성한 Jenkins + CodeDeploy를 활용한 EC2에 스프링 부트 프로젝트 배포 자동화의 설정과 거의 동일합니다.
그렇기 때문에 공통적인 부분은 제외하고 진행하도록 하겠습니다.


EC2 준비

도커 이미지로 애플리케이션을 관리하기 때문에, ec2에 직접 자바를 설치할 필요가 없어졌습니다.
보안 그룹, nginx, jenkins를 제외한 나머지는 이전 글과 동일하게 진행합니다.

보안 그룹

22(ssh), 80(nginx), 9000(jenkins)만 허용해주면 됩니다.

jenkins

**/*.jar, **/appspec.yml, **/scripts/* Dockerfile

기존 설정에서 빌드 파일 배포 후 도커 이미지를 생성하기 위한 Dockerfile도 포함했습니다.

nginx

# aws ec2 linux nginx 설치(nginx1) 
sudo amazon-linux-extras install nginx1 -y

# nginx 설치 확인
nginx -v
nginx version: nginx/1.20.0

# nginx 시작
sudo service nginx start
Redirecting to /bin/systemctl start nginx.service

nginx가 정상적으로 동작하는지 확인한 뒤 나머지 작업을 진행합니다.

# 리버스 프록시 설정
# 기본 설정은 유지
sudo vim /etc/nginx/nginx.conf
server {
    include /etc/nginx/conf.d/service-url.inc;

    location / {
            proxy_pass $service_url;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forworded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;
    }
}

# 리버스 프록시 설정
# 배포할 때 마다 포트 변경
# real1 -> 8081, real2 -> 8082
# 첫 번째 배포 시에 정상적으로 포트가 설정되는지 확인하기 위해 8082로 설정
set $service_url http://127.0.0.1:8082;

# nginx 재시작
sudo service nginx restart
Redirecting to /bin/systemctl restart nginx.service

/etc/nginx/nginx.conf의 경우 다음과 같이 작성됩니다.


빌드 설정

Dockerfile

FROM openjdk:11-jdk

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} cicd-test.jar

ARG IDLE_PROFILE
ENV ENV_IDLE_PROFILE=$IDLE_PROFILE

ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=${ENV_IDLE_PROFILE}", "/cicd-test.jar"]

-Dspring.profiles.active를 통해 실행할 profile을 지정했습니다.

  • Dspring.profiles.active=${IDLE_PROFILE}와 같이 환경변수를 직접 지정하는 경우 인식을 못하기 때문에 ARGENV를 사용해 우회했습니다.

appspec.yml

# appspec.yml

version: 0.0
os: linux
files:
  - source:  /
    destination: /home/ec2-user/cicd-test
    overwrite: yes

permissions:
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user

hooks:
  ApplicationStop:
    - location: scripts/stop.sh
      timeout: 360
      runas: ec2-user
  ApplicationStart:
    - location: scripts/start.sh
      timeout: 360
      runas: ec2-user
  ValidateService:
    - location: scripts/health.sh
      timeout: 360
      runas: ec2-user

code deployhook에 따라 실행할 스크립트를 분리했습니다.

  • ApplicationStop
    • 해당 hook의 경우 이전 버전 애플리케이션을 종료할 때 사용합니다.
    • stop.sh 스크립트를 실행시켜 기존에 실행되고 있던 도커 컨테이너를 종료하고, 이미지를 삭제해주었습니다.
  • ApplicationStart
    • 해당 hook의 경우 최신 버전 애플리케이션을 실행할 때 사용합니다.
    • start.sh 스크립트를 실행시켜 jenkins가 빌드한 jar를 통해 새로운 도커 이미지를 생성하고, 도커 컨테이너를 실행하도록 했습니다.
  • ValidateService
    • 해당 hook의 경우 배포가 성공적으로 완료되었는지에 대한 검증 로직을 실행할 때 사용합니다.
    • health.sh을 통해 최신 버전 애플리케이션이 정상적으로 동작하는지 확인하고, nginx의 리버스 프록시 설정을 변경하도록 했습니다.

스크립트 작성

profile.sh

#!/bin/bash

function find_profile()
{
    CURRENT_PROFILE=$(curl -s http://localhost/profile)

    if [ $CURRENT_PROFILE == real1 ]
    then
        IDLE_PROFILE=real2
    elif [ $CURRENT_PROFILE == real2 ]
    then
        IDLE_PROFILE=real1
    else
        IDLE_PROFILE=real1
    fi

    echo "${IDLE_PROFILE}"
}

function find_port()
{
    IDLE_PROFILE=$(find_profile)

    if [ $IDLE_PROFILE == real1 ]
    then
        IDLE_PORT=8081
    elif [ $IDLE_PROFILE == real2 ]
    then
        IDLE_PORT=8082
    fi

    echo "${IDLE_PORT}"
}

function find_switch_port()
{
    CONTAINER_ID=$(sudo docker ps -f "ancestor=real2" -q)

    if [ -z $CONTAINER_ID ]
    then
        echo "8081"
    else
        find_port
    fi
}

각 스크립트에서 자주 사용하는 기능들을 따로 함수로 분리했습니다.

  • find_profile
    • 어떤 profile로 새 버전의 애플리케이션을 실행할지 확인하는 함수입니다.
    • real1이 동작 중이면 real2로, real2가 동작 중이면 real1이 동작하도록 했습니다.
    • 첫 배포 시에는, real1 / real2 모두 동작하고 있지 않기 때문에 real1을 반환하도록 했습니다.
  • find_port
    • 어떤 포트로 새 버전의 애플리케이션을 실행할지 확인하는 함수입니다.
    • 내부적으로 find_profile을 호출하도록 했습니다.
    • real1일 때는 8081, real2일 때는 8082로 실행하도록 했습니다.
  • find_switch_port
    • 첫 배포와 이후 배포의 차이점 때문에 추가한 함수입니다.
    • 첫 배포 시에는 무조건 real1을 반환하므로 real2가 도커 컨테이너로 실행되어 있는지 확인합니다.
      • real2를 실행하는 도커 컨테이너가 없다면 첫 배포이므로, 8081을 반환합니다.
      • real2를 실행하는 도커 컨테이너가 있다면 첫 배포가 아니므로, find_port를 호출합니다.

stop.sh

#!/bin/bash

echo "> stop 시작" >> /home/ec2-user/deploy.log

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh

IDLE_PROFILE=$(find_profile)

echo "> 실행할 profile : $IDLE_PROFILE " >> /home/ec2-user/deploy.log

CONTAINER_ID=$(sudo docker ps -f "ancestor=${IDLE_PROFILE}" -q)

if [ -z $CONTAINER_ID ]
then
    echo "> 기존에 실행되고 있던 컨테이너가 없습니다. " >> /home/ec2-user/deploy.log
else
    echo "> 기존에 실행되고 있던 컨테이너 : $CONTAINER_ID" >> /home/ec2-user/deploy.log
    sudo docker stop $CONTAINER_ID
    sudo docker rm $CONTAINER_ID
    echo "> 컨네이너 종료 완료, $CONTAINER_ID" >> /home/ec2-user/deploy.log
    sudo docker image rm $IDLE_PROFILE
    echo "> 기존 이미지 삭제 완료, $IDLE_PROFILE" >> /home/ec2-user/deploy.log
    sleep 10
fi

profile.sh에서 실행해야 할 profile을 조회합니다.
profile로 최신 버전의 애플리케이션을 배포해야 하므로, 해당 profile이 도커에 있는지를 확인하고, 있다면 종료합니다.

도커 이미지의 경우 이름이 동일하면 이전 버전은 댕글링 이미지가 되기 때문에 삭제해주었습니다.

start.sh

echo "> start 시작" >> /home/ec2-user/deploy.log

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh

IDLE_PROFILE=$(find_profile)
IDLE_PORT=$(find_port)

echo "> 도커 이미지 파일을 생성합니다." >> /home/ec2-user/deploy.log
sudo docker build --build-arg IDLE_PROFILE=${IDLE_PROFILE} -f /home/ec2-user/cicd-test/Dockerfile -t ${IDLE_PROFILE} /home/ec2-user/cicd-test >> /home/ec2-user/deploy.log 2>/home/ec2-user/deploy_err.log
sleep 30

echo "> 도커 컨테이너를 실행합니다." >> /home/ec2-user/deploy.log
sudo docker run -p ${IDLE_PORT}:${IDLE_PORT} ${IDLE_PROFILE} >> /home/ec2-user/spring.log 2>/home/ec2-user/deploy_err.log &
sleep 10

profile.sh에서 실행해야 할 profileport를 가져와서 도커 이미지를 생성한 후, 도커 컨테이너를 실행합니다.

도커 이미지의 경우 간단하게 profile 명으로 지정했습니다.

도커 컨테이너는 &으로 백그라운드로 실행하도록 했습니다.

switch.sh

#!/bin/bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh

function switch_nginx_proxy()
{
    IDLE_PORT=$(find_switch_port)

    echo "> 실행 포트 : $IDLE_PORT" >> /home/ec2-user/deploy.log
    echo "> /etc/nginx/conf.d/service-url.inc 변경" >> /home/ec2-user/deploy.log
    echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc

    echo "> nginx 재시작" >> /home/ec2-user/deploy.log
    sudo service nginx restart
}

profile.sh를 통해 실행 포트를 찾아옵니다.

이후 nginx의 리버스 프록시 설정을 변경해줍니다.
변경 이후 nginx를 재시작합니다.

health.sh

#!/bin/bash

ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)

source ${ABSDIR}/profile.sh
source ${ABSDIR}/switch.sh

IDLE_PORT=$(find_switch_port)

echo "> 새롭게 실행한 애플리케이션 health 확인 " >> /home/ec2-user/deploy.log
echo "> 실행 포트 : $IDLE_PORT" >> /home/ec2-user/deploy.log

for CNT in {1..10}
do
    echo "> health 확인용 반복문 시작... $CNT 회" >> /home/ec2-user/deploy.log
    UP=$(curl -s http://127.0.0.1:${IDLE_PORT}/application/health | grep 'UP')
    if [ -z "${UP}" ]
    then
        echo "> 아직 애플리케이션이 시작되지 않았습니다." >> /home/ec2-user/deploy.log
    else
        echo "> 애플리케이션이 정상적으로 실행되었습니다." >> /home/ec2-user/deploy.log
        switch_nginx_proxy
        break ;
    fi

    sleep 10
done

if [ $(CNT) -eq 10 ]
then
    echo "> 애플리케이션 실행에 실패했습니다." >> /home/ec2-user/deploy.log
    exit 1
fi

반복문을 사용해 최신 버전 애플리케이션이 정상적으로 실행되었는지 확인합니다.
이 때 actuator을 사용합니다.

지정해놓은 uri/application/health로 요쳥했을 때 값이 있다면 정상적으로 애플리케이션이 실행된 것이므로, switch_nginx_proxy를 통해 service-uri.inc를 변경해줍니다.

반복문을 10번 반복할 때 까지 애플리케이션이 제대로 실행되지 않았다면, 배포에 실패한 것으로 간주합니다.


배포

첫 번째 배포

sudo docker images
REPOSITORY        TAG       IMAGE ID       CREATED      SIZE
jenkins/jenkins   jdk11     ceea1a18418a   4 days ago   460MB

# service-url.inc
set $service_url ;

첫 번째 배포 전에는 실행 중인 도커 이미지는 jenkins 뿐입니다.
service-uri.inc의 경우 확인하기위해 비워둔 상태입니다.

sudo docker images
REPOSITORY        TAG       IMAGE ID       CREATED              SIZE
real1             latest    35428282fa06   About a minute ago   679MB
jenkins/jenkins   jdk11     ceea1a18418a   4 days ago           460MB
openjdk           11-jdk    72d6966f5c18   7 days ago           660MB

첫 번째 배포를 완료한 뒤 도커 이미지를 확인해보면, real1 이미지가 추가된 것을 확인할 수 있습니다.

80 포트로 접근하면 real1을 반환합니다.

# service-url.inc

set $service_url http://127.0.0.1:8081;

service-url.inc의 경우 real1의 포트인 8081로 변경되었습니다.

두 번째 배포

sudo docker images
REPOSITORY        TAG       IMAGE ID       CREATED          SIZE
real2             latest    82d9af46531b   About a minute ago    679MB
real1             latest    35428282fa06   4 minutes ago   679MB
jenkins/jenkins   jdk11     ceea1a18418a   4 days ago       460MB
openjdk           11-jdk    72d6966f5c18   7 days ago       660MB

real2 이미지가 추가되었습니다.

80 포트로 접근하면 real2를 반환합니다.

# service-url.inc

set $service_url http://127.0.0.1:808;

service-url.inc의 경우 real2의 포트인 8082로 변경되었습니다.

세 번째 배포

sudo docker images
REPOSITORY        TAG       IMAGE ID       CREATED              SIZE
real1             latest    2c1a5e8310cf   About a minute ago   679MB
real2             latest    82d9af46531b   12 minutes ago       679MB
jenkins/jenkins   jdk11     ceea1a18418a   4 days ago           460MB
openjdk           11-jdk    72d6966f5c18   7 days ago           660MB

real1 컨테이너 종료 -> real1 컨테이너 삭제 -> real1 이미지 삭제 -> 최신 버전의 애플리케이션으로 real1 이미지 재생성의 과정을 거치기 때문에 댕글링 이미지가 발생하지 않고 정상적으로 real1 이미지가 생성되었습니다.

80 포트로 접근하면 real1을 반환합니다.

# service-url.inc

set $service_url http://127.0.0.1:8081;

service-url.inc의 경우 real1의 포트인 8081로 변경되었습니다.

이를 통해 첫 번째 배포 시에도 정상적으로 동작하고, 이후 동일한 profile의 이미지를 생성하고 실행할 때에도 정상적으로 동작하는 것을 확인할 수 있었습니다.

profile
안녕하세요

0개의 댓글