본문 바로가기
Develop/CS

무중단 배포 (feat: Jenkins & Shell)

by 연로그 2022. 10. 19.
반응형

목차

  1. 서론
  2. 종류
    • Rolling Update
    • Blue Green
    • 어떤 것을 선택했는가?
  3. 무중단 배포 예제 (feat: Jenkins, Shell Script)

 


1. 서론 💥

 

 지난 번에 우테코 레벨 3 인터뷰를 진행했을 때 받은 질문이 있습니다. '24시간 돌아가는 서비스에서 어떻게 새로운 변경 사항을 적용할 수 있을까요?'. 당시에는 무중단 배포에 대한 경험도 키워드도 몰랐기 때문에 서비스를 잠시 중단하고 배포한 뒤 다시 시작하는 경우밖에 생각이 나지 않는다고 대답했다. 기존 데이터에 영향을 주지 않으면서 무중단으로 서비스를 제공하는 방법에 대해 알아보면 좋을 것 같다는 조언을 듣고 이번 기회에 줍줍에 무중단 배포를 시도하기로 했다.

 


2. 종류 🔮

💜 Rolling Update

파랑: 이전 버전 / 초록: 새 버전

  • 이전 버전에서 새 버전으로 점진적으로 대체
  • 새 버전과 이전 버전이 공존

 

 

💜 Blue Green

파랑: 이전 버전 / 초록: 새 버전
파랑: 이전 버전 / 초록: 새 버전

  • 동일한 환경의 서버에 새 버전을 배포하고 라우팅 설정 변경
  • 안정적이고 이전 버전으로 되돌리기 쉬움
  • 서버 비용이 많이 듦

 

 

💜 어떤 것을 선택했는가?

 최종적으로는 Blue-Green을 선택했습니다. 우테코에서 무료로 AWS EC2를 지원해주고 있기 때문에 서버비용이나 인스턴스 개수에 대한 부담이 없기에 택할 수 있던 방법입니다.

 

 Rolling Update의 경우에는 버전간 호환성이 지원이 되어야 하는데요. 현재 변경된 사항들이 이전 버전에서는 지원하지 않기 때문에 한번에 변경되어야 한다고 생각했습니다. 이번 릴리즈 이후에 추가 개발을 진행할 때는 버전 호환성을 고려하며 개발해 해당 방법을 적용해보는 것도 좋을 것 같습니다. 

 

 여러 배포 방법을 찾다가 Canary 배포 방법에 대해서도 알게 되었는데요. 일부 사용자들에게만 새로운 버전을 배포해 정상적으로 작동하는지 또는 사용자들의 피드백을 받아 어떤 버전을 선택할지 등의 케이스에서 사용할 수 있는 방법입니다. 현재 저희 서비스에서는 유의미한 피드백을 받을만큼의 트래픽이 존재하지 않아 적용하지 않았습니다.

 

 


3. 무중단 배포 예제 (feat: Jenkins, Shell Script) ✨

 

현재 줍줍에서는 배포를 Jenkins를 통해 진행하고 있습니다.

기존의 Jenkins 스크립트 과정은 아래와 같습니다.

 

  1. GitHub의 프로젝트 repository clone
  2. build를 통해 jar 생성
  3. WAS 인스턴스에 jar 전송
  4. 배포 스크립트 실행

 

이제는 아래처럼 nginx가 어떤 WAS EC2를 바라보는지 설정도 다시 해주어야 합니다.

 

 

무중단 배포를 할 Jenkins의 스크립트는 아래와 같은 순서로 진행됩니다.

  1. 현재 사용중인 EC2의 IP와 사용중이지 않은 EC2의 IP를 찾는다
  2. 사용 중이지 않은 EC2에 변경된 jar를 배포한다
  3. nginx의 연결 설정을 변경하고 재시작을 한다
  4. 원래 사용하던 EC2를 종료한다

 

두 과정을 합하려니 엄청 복잡해보입니다. 하나하나 정리해봅시다! (예제 코드는 Jenkins Scripted Pipeline을 이용합니다.)

 

1. GitHub 레포지토리 clone

줍줍의 Jenkins에서는 아래와 같은 2개의 레포지토리를 이용하고 있습니다. 해당 레포지토리들을 가져오는 스크립트를 작성해봅시다.

  • 프로젝트의 애플리케이션 로직들이 담긴 레포지토리
  • 배포에서 사용할 다양한 스크립트를 담은 레포지토리
node {
    stage('Clone from GitHub') {
        git branch: 'main', url: 'https://github.com/woowacourse-teams/2022-pickpick.git' //프로젝트 레포
        git branch: 'main', url: 'https://github.com/2022-pickpick/deploy.git' //설정파일이 담긴 레포
    }
}


2. jar 생성

무사히 clone을 받아왔으면 프로젝트를 build 시킵니다.

node {
    // clone
    stage('Build') {
        sh '''
            # cd 프로젝트_위치
            chmod +x ./gradlew
            ./gradlew clean bootJar --info
            cd ..
        '''
    }
}

 

3. 현재 사용중인 EC2의 IP와 사용중이지 않은 EC2의 IP를 찾는다

현재 사용중인 IP를 찾기 위해 find-address.sh라는 쉘 스크립트를 만들었다. (WAS가 2개일 때만 가능한 스크립트이다.)

#!/bin/sh

SERVER_1=1번_WAS_IP
SERVER_2=2번_WAS_IP

SERVER_1_STATUS=$(timeout 5 bash -c 'cat < /dev/null > /dev/tcp/'SERVER_1'/8080'; echo $?)

if [ ${SERVER_1_STATUS} -eq 0 ]
then
        echo "${SERVER_1}"
        echo "${SERVER_2}"
else
        echo "${SERVER_2}"
        echo "${SERVER_1}"
fi

SERVER_1_STATUS=$(timeout 5 bash -c 'cat < /dev/null > /dev/tcp/'SERVER_1'/8080'; echo $?)

  • SERVER_1_STATUS에 2번_WAS_IP의 8080 포트가 동작중인지에 대한 상태를 저장한다.
  • 동작중이라면 0이 반환된다.

 

echo를 통해 동작중인 WAS의 IP를 첫번째, 동작중이지 않은 WAS IP를 두번째로 찍어줬다. 이 출력된 값을 Jenkins에서 이용할 예정이다. 2개의 echo를 사용했지만 sh 파일을 실행한 결과는 'IP주소1 IP주소2' 형식으로 한 줄처럼 보인다. 이를 split 한 다음에 각각 global_running_address와 global_stopped_address라는 변수에 담아주었다.

node {
    // clone, build
    
    def global_running_address=null
    def global_stopped_address=null

    stage('find IP') {
        sh 'chmod +x find-address.sh'
        addresses=sh(returnStdout: true, script: 'echo $(./find-address.sh)')
        (global_running_address, global_stopped_address) = addresses.tokenize(' ')
        global_stopped_address = global_stopped_address.trim()
    }
}

 

4. 미사용 EC2에 신규 버전 jar 배포

2번에서 생성했던 jar를 3번에서 찾았던 사용중이지 않은 EC2에 배포한다.

deploy.sh라는 jar를 실행시키는 스크립트를 생성하여 사용했다.

node {
    // clone, build, find IP
    
    def global_running_address=null
    def global_stopped_address=null 
    
    withCredentials([sshUserPrivateKey(credentialsId: "key-pickpick", keyFileVariable: 'my_private_key_file')]) {
        stage("deploy new") {
            sh "echo deploy.sh"
            dir ('./') {
                scp(global_stopped_address,'deploy.sh','/home/ubuntu')
                chmod(global_stopped_address,'deploy.sh')
            }
            sh "echo 'Deploy AWS'"
            dir('backend/build/libs') {
                scp(global_stopped_address,'*.jar','/home/ubuntu')
            }
            execute(global_stopped_address,'deploy.sh')
        }
    }
}

def execute(ip, file) {
    sh 'ssh -i ${my_private_key_file} ubuntu@'+ip+' "./'+file+'"'
}

def scp(ip, from, to) {
    sh 'scp -o StrictHostKeyChecking=no -i ${my_private_key_file} '+from+' ubuntu@'+ip+':'+to
}

def chmod(ip, file) {
    sh 'ssh -i ${my_private_key_file} ubuntu@'+ip+' "chmod +x ./'+file+'"'
}

 

🔻 스크립트 세부 설명

더보기
  • withCredentials
    • 3에서 찾은 EC2에 접근하려면 권한이 필요
    • 해당 권한들을 이용하기 위해 Jenkins에 미리 설정했던 값들을 이용

  •  scp -i {pem 키} {파일명} ubuntu@{IP주소}:/{경로}
    • IP주소에 접근하기 위해 pem키 사용
    • 파일을 경로 위치에 복사

  • ssh -i {pem 키} ubuntu@{IP주소} '명령어'
    • IP주소에 접근하기 위해 pem키 사용
    • 해당 IP에 접근하여 명령어 수행

  • execute, scp, chmod
    • 편리한 스크립트 작성을 위해 생성한 custom function

 

🔻 deploy.sh 예제

더보기
#!/bin/sh

IDLE_PID=$(pgrep -f jar파일명.jar)
echo "> 애플리케이션 pid : $IDLE_PID"

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

  echo "> kill -9 $IDLE_PID"

  KILL_COMMAND=$(kill -9 ${IDLE_PID})

  if [ -z ${KILL_COMMAND}]
  then
    echo "> 프로세스를 무사히 종료했습니다"
  fi
fi

echo "> 새 애플리케이션 배포"
nohup java -Duser.timezone=Asia/Seoul -jar jar파일명.jar > /dev/null 2>&1 &

 

 

5. NGINX 설정 변경 & reload

service-url.inc에는 $service_url이라는 변수 값을 세팅해주고 있습니다. nginx의 설정 파일에서 해당 파일을 include하면 세팅했던 변수 값을 사용할 수 있습니다.

server {
    include /etc/nginx/conf.d/service-url.inc;
    # ...
    location /api {
        proxy_pass http://$service_url:8080;
        # ...
    }
}

 

service-url.inc라는 파일을 생성해줄 nginx-service-url.sh이라는 파일을 생성했습니다.

echo 'set $service_url' $1';' > /etc/nginx/conf.d/service-url.inc

 

이렇게 복잡한 구조로 service-url.inc를 생성하는 이유는 sh파일을 실행시킬 때 인자를 넘길 수 있기 때문입니다. './파일명.sh 인자1 인자2 인자3' 식으로 값을 나열하면 sh파일에서 해당 값을 $1, $2, $3 식으로 사용할 수 있습니다. 아래 예제를 통해 이해하시길 바랍니다.

 

$ ./nginx-service-url.sh jupjup.site
# service-url.inc의 파일 내용 >> set $service_url jupjup.site;

$ ./nginx-service-url.sh helloWorld
# service-url.inc의 파일 내용 >> set $service_url helloWorld;

 

위 스크립트를 이용해 nginx의 설정을 바꾸고 reload까지 시켜주는 Jenkins 스크립트를 짜봅시다.

node {
    // clone, build, find IP
    
    def global_running_address=null
    def global_stopped_address=null 
    
    withCredentials([sshUserPrivateKey(credentialsId: "key-pickpick", keyFileVariable: 'my_private_key_file')]) {
        // deploy
        
        stage("configure nginx") {
            def nginx_address='nginx의 IP'
            scp(nginx_address,'nginx-service-url.sh','/home/ubuntu')
            chmod(nginx_address,'nginx-service-url.sh')
            sh 'ssh -i ${my_private_key_file} ubuntu@'+nginx_address+' "sudo ./nginx-service-url.sh" '+global_stopped_address
            sh 'ssh -i ${my_private_key_file} ubuntu@'+nginx_address+' "sudo service nginx reload"'
        }
    }
}
// execute, scp, chmod - 4번 코드 또는 전체 참고

 

 

6. 기존 버전을 사용하던 EC2 종료

4~5를 거쳐서 신규 버전의 jar를 배포하고, nginx를 신규 버전과 연결해주었습니다. 이제 기존 버전의 EC2를 종료합시다. 애플리케이션을 종료하기 위해 kill.sh라는 스크립트 파일을 생성했습니다.

IDLE_PID=$(pgrep -f jar파일명.jar)

if [ -z ${IDLE_PID} ]
then
  echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> $IDLE_PID 을 종료합니다."
  KILL_COMMAND=$(kill -15 ${IDLE_PID})

  if [ -z ${KILL_COMMAND}]
  then
    echo "> 프로세스를 무사히 종료했습니다"
  fi
fi

 

Jenkins 파일은 아래와 같습니다.

node {
    // clone, build, find IP
    
    def global_running_address=null
    def global_stopped_address=null 
    
    withCredentials([sshUserPrivateKey(credentialsId: "key-pickpick", keyFileVariable: 'my_private_key_file')]) {
        // deploy, confiture nginx
        stage("kill original") {
            scp(global_running_address,'kill.sh','/home/ubuntu')
            chmod(global_running_address,'kill.sh')
            execute(global_running_address,'kill.sh')
        }
    }
}
// execute, scp, chmod - 4번 코드 또는 전체 참고

 

🔻 Jenkins 전체 스크립트

더보기
node {
    stage('Clone from GitHub') {
        git branch: 'main', url: 'https://github.com/woowacourse-teams/2022-pickpick.git' //프로젝트 레포
        git branch: 'main', url: 'https://github.com/2022-pickpick/deploy.git' //설정파일이 담긴 레포
    }
    
    stage('Build') {
        sh '''
            # cd 프로젝트_위치
            chmod +x ./gradlew
            ./gradlew clean bootJar --info
            cd ..
        '''
    }
    
    def global_running_address=null
    def global_stopped_address=null

    stage('find IP') {
        sh 'chmod +x find-address.sh'
        addresses=sh(returnStdout: true, script: 'echo $(./find-address.sh)')
        (global_running_address, global_stopped_address) = addresses.tokenize(' ')
        global_stopped_address = global_stopped_address.trim()
    }
    
    withCredentials([sshUserPrivateKey(credentialsId: "key-pickpick", keyFileVariable: 'my_private_key_file')]) {
        stage("deploy new") {
            sh "echo deploy.sh"
            dir ('./') {
                scp(global_stopped_address,'deploy.sh','/home/ubuntu')
                chmod(global_stopped_address,'deploy.sh')
            }
            sh "echo 'Deploy AWS'"
            dir('backend/build/libs') {
                scp(global_stopped_address,'*.jar','/home/ubuntu')
            }
            execute(global_stopped_address,'deploy.sh')
        }
        
        stage("configure nginx") {
            def nginx_address='nginx의 IP'
            scp(nginx_address,'nginx-service-url.sh','/home/ubuntu')
            chmod(nginx_address,'nginx-service-url.sh')
            sh 'ssh -i ${my_private_key_file} ubuntu@'+nginx_address+' "sudo ./nginx-service-url.sh" '+global_stopped_address
            sh 'ssh -i ${my_private_key_file} ubuntu@'+nginx_address+' "sudo service nginx reload"'
        }
        
        stage("kill original") {
            scp(global_running_address,'kill.sh','/home/ubuntu')
            chmod(global_running_address,'kill.sh')
            execute(global_running_address,'kill.sh')
        }
    }
}


def execute(ip, file) {
    sh 'ssh -i ${my_private_key_file} ubuntu@'+ip+' "./'+file+'"'
}

def scp(ip, from, to) {
    sh 'scp -o StrictHostKeyChecking=no -i ${my_private_key_file} '+from+' ubuntu@'+ip+':'+to
}

def chmod(ip, file) {
    sh 'ssh -i ${my_private_key_file} ubuntu@'+ip+' "chmod +x ./'+file+'"'
}

 

 


도움 & 참고

반응형