서버 이중화 작업

yboy·2024년 8월 11일
0

Learning Log 

목록 보기
32/41

Situation

현재 운영 중인 서비스중 현재 단일 서버, 중단배포 방식으로 배포가 이루어지는 서비스가 있다는 사실을 알게 됐고 이를 문제라고 생각해 서버 이중화 작업을 하기로 결정했다. 그 기록을 글로 남기고자 한다.
우선 단일 서버, 중단 배포 방식이 문제가 되는 이유는

  1. 운영되고 있는 하나의 서버가 다운되면 서비스를 아예 운영할 수 없게 된다.
  2. 중단배포 방식으로 배포하게 되면 배포되는 일정 시간동안 고객이 서비스를 사용할 수 없다.

해당 서비스는 현재 밤, 낮 할 것 없이 24시간 운영되는 서비스여서 지금처럼 운영된다면 추후에 문제가 발생할 가능성이 농후하다고 판단하였다.

Solution

우선 기존의 인프라 구조도를 대략적으로 살펴보면 아래와 같다. 하나의 EC2 서버에 운영중인 서버 4대가 4개의 포트에 각각 띄워져 있고 Load Balancer가 prefix url을 통해 각각의 요청을 적합한 서버로 포워딩해주고 있다.

EC2 추가 및 기존 타겟그룹 수정

첫 번째로 해야 할 작업은 새로운 EC2서버 한 대를 더 띄우 Load Balancer에서 같은 요청에 대한 트래픽을 새로운 서버로 분산시켜줘야 한다.
현재 AWS를 사용하고 있기 때문에 ALB의 리스너 규칙의 기존 타켓그룹에 새로운 ec2, port를 추가해주면 간단하게 트래픽을 분산시킬 수 있다. 설정을 통해 아래와 같은 구조가 되었다. 혹시 Load Balancer에 대해 궁금하다면 아래 포스팅을 참고하길 바란다.
https://velog.io/@myspy/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-10%EC%B4%88%EC%9D%98-%EB%8B%A4%EC%9A%B4-%ED%83%80%EC%9E%84-0.2%EC%B4%88

서버 이중화에 따른 CI/CD

다음으로 고민해봐야 할 점은 서버 이중화를 함에 따라 어떻게 CI/CD를 구축해야 할지이다. 사실 이 부분이 핵심이라고 볼 수 있다.
우선 기존 단일서버일 때의 배포방식을 살펴보자.

  1. bitbucket에 코드를 올린다.
  2. Webhook을 통해 Jenkins가 이를 감지한다.
  3. rm -f {project_folder} && git clone
  4. build를 하고 jar 파일 산출후, scp명령을 통해 jar를 해당 서버로 보낸다.
  5. 해당 서버의 jar를 ssh로 실행시킨다.

기존 CI/CD 배포방식은 위와 같고 이를 아래과 같이 수정하기로 결정했다.
혹시나 실수가 발생할 수 있기 때문에 build와 deploy pipeline은 각각 나누고 bitbucket에 걸려있던 webhook을 제거하고 jenkins UI의 버튼을 누를 시에 trigger가 발생하도록 수정하였다.
build

  1. rm -f {project_folder} && git clone
  2. build후 jar 파일 산출

deploy

  1. aws target group deregister
  2. 기존에 실행되고 있는 서버 kill
  3. scp 명령을 통해 build를 통해 산출된 jar 파일 실행
  4. server healthcheck
  5. aws target group register
  6. aws target group health check
  7. 2번 ec2도 1~6번을 반복 (롤링배포)

바뀐 부분의 핵심은 target group에서 배포가 이루어질 서버를 한 대씩 deregister하고 서버 배포후, 문제가 없으면 register하는 것이라고 볼 수 있다. 또한 롤링배포 방식을 통해 무중단 배포방식을 도입하였다.
target group deregister를 하는 이유는 배포를 하는 동안 정상동작하지 않을 해당 서버로 트래픽이 가면 안되기 때문이다.
그럼 이제 적성한 pipeline을 알아보자. pipeline은 groovy 문법으로 작성되었다.

Jenkins build pipeline

build 하는 부분은 딱히 살펴볼 내용은 없어서 바로 deploy pipeline으로 넘어가 보도록 하자.

node {
    withCredentials([sshUserPrivateKey(credentialsId: 'laundrygo_was', keyFileVariable: 'jenkins')]) {
    withCredentials([sshUserPrivateKey(credentialsId: 'jenkins_ssh_key', keyFileVariable: 'jenkins@laundry24-jenkins-dev')]) {
    stage("rm project folder") {
        try {
            sh "rm -f -r /var/lib/jenkins/workspace/${project_folder_name}"
        } catch (Exception e) {
            // slackSend(channel: '#build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] project folder remove fail branch: ${BRANCH}")
            throw e
        }
    }
    stage("git clone project") {
        try {
            sh "cd /var/lib/jenkins/workspace"
            sh "[ -d /var/lib/jenkins/workspace/${project_folder_name} ] && echo '${project_folder_name} Found' || git clone ${git_url} /var/lib/jenkins/workspace/${project_folder_name}"
        } catch (Exception e) {
            // slackSend(channel: '#build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] git clone fail branch: ${BRANCH}")
            throw e
        }
    }
	 stage("git checkout") {
	    try {
         sh "cd /var/lib/jenkins/workspace/${project_folder_name} && git checkout ${BRANCH}"
       } catch (Exception e) {
        //   slackSend(channel: '#build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] git branch checkout fail branch: ${BRANCH}")
          throw e
       }
    }
	stage("start noti "){
		  try {
        //  slackSend (channel: '#build-notification', color: '#008cff', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 빌드시작 ${LASTCOMMIT} | branch: ${BRANCH}")
       } catch (Exception e) {
        //   slackSend (channel: '#build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 빌드실패 branch: ${BRANCH}")
          throw e
       }	
	}
	stage("maven build"){
		try{
		    sh "cd /var/lib/jenkins/workspace/${project_folder_name} && chmod +x ./mvnw && ./mvnw clean package"
		} catch(Exception e) {
			 //slackSend (channel: '#build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 빌드실패 branch: ${BRANCH}")
          throw e
		}
	}
	stage("success noti"){
		 try {
        // slackSend (channel: '#build-notification', color: '#008cff', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 빌드성공 branch: ${BRANCH}")
      } catch (Exception e) {
        // slackSend (channel: '#build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 빌드실패 branch: ${BRANCH}")
         throw e
      }
    }
}
}
}

Jenkins deploy pipeline

 node {
    withCredentials([sshUserPrivateKey(credentialsId: 'laundrygo_was', keyFileVariable: 'pem-key')]) {
    withCredentials([sshUserPrivateKey(credentialsId: 'jenkins_ssh_key', keyFileVariable: 'jenkins@laundry24-jenkins-dev')]) {
        withAWS(credentials: 'jenkins-aws', region: 'ap-northeast-2') {
            stage("start noti") {
                try {
                    // LASTCOMMIT = sh(script: "cd /var/lib/jenkins/workspace/${project_folder_name} && git log --pretty=format:\"author: %an | message: %s\" | head -n 1", returnStdout: true).trim()
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#008cff', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 배포시작 ${LASTCOMMIT} | branch: ${BRANCH}")
                    // wrap([$class: 'BuildUser']) {
                        // def user = env.BUILD_USER_ID
                        // slackSend(channel: '#공지-개발배포', message: "${project_folder_name} ${tag}(${BRANCH}) 배포합니다.")
                    // }
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 배포실패 branch: ${BRANCH}")
                    throw e
                }
            }
             stage("aws target deregister 1") {
                try {
                    sh "aws elbv2 deregister-targets --target-group-arn ${target_group_arn} --targets Id=${instance_id_1},Port=${port}"
                    // sleep 20
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] load balancing taget remove fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("deploy 1") {
                try {
                    // Get the PID from the remote server and store it in a variable
                    def command = "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_1} sudo lsof -ti tcp:${port}"
                    def PID = sh(script: command, returnStdout: true).trim()
                    // Echo the PID
                    echo "PID: ${PID}"
                    // kill 
                    sh "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_1} sudo kill ${PID}"
                    // scp jar 
				    sh "cd ~/workspace/${project_folder_name}/target"
					sh "scp -i /var/lib/jenkins/laundrygo_was.pem /var/lib/jenkins/workspace/l24-api/target/*.jar ec2-user@${ip_1}:~/${project_folder_name}.jar"
                    // execute jar 
                    sh "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_1} nohup /lib/java/openjdk-8u342-b07/bin/java -jar -Dspring.profiles.active=dev -Dserver.port=8083 l24-api.jar &"
                    sleep 20
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] deploy.sh fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("application health check 1") {
                try {
                    healthCheckUrl="http://${ip_1}:${port}${health_check_url}"
                    script {
                        def responseCode = sh(script: "curl -o /dev/null -w '%{http_code}' ${healthCheckUrl}", returnStdout: true).trim()
                        if (responseCode != '200') {
                            // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 배포실패 branch: ${BRANCH}")
                        }
                    }
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] application health check fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("aws target register 1") {
                try {
                    sh "aws elbv2 register-targets --target-group-arn ${target_group_arn} --targets Id=${instance_id_1},Port=${port}"
                    sleep 20
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] load balancing taget regist fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("aws target health check 1") {
                try {
                    sh "aws elbv2 describe-target-health --target-group-arn ${target_group_arn} --targets Id=${instance_id_2},Port=${port} | jq '.TargetHealthDescriptions[0].TargetHealth.State' > ~/loadbalencingHealthCheckResult.txt"
                    // sh "~/loadbalencingHealthCheck.sh"
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] load balancing health check fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("aws target deregister 2") {
                try {
                    sh "aws elbv2 deregister-targets --target-group-arn ${target_group_arn} --targets Id=${instance_id_2},Port=${port}"
                    // sleep 20
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] load balancing taget remove fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("deploy 2") {
                try {
                    // Get the PID from the remote server and store it in a variable
                    def command = "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_2} sudo lsof -ti tcp:${port}"
                    def PID = sh(script: command, returnStdout: true).trim()
                    // Echo the PID
                    echo "PID: ${PID}"
                    // kill 
                    sh "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_2} kill ${PID}"
                    // scp jar 
				    sh "cd ~/workspace/${project_folder_name}/target"
					sh "scp -i /var/lib/jenkins/laundrygo_was.pem /var/lib/jenkins/workspace/l24-api/target/*.jar ec2-user@${ip_2}:~/${project_folder_name}.jar"
                    // execute jar 
                    sh "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_2} nohup /lib/java/openjdk-8u342-b07/bin/java -jar -Dspring.profiles.active=dev -Dserver.port=8083 l24-api.jar &"
                    sleep 20
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] deploy.sh fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("application health check 2") {
                try {
                    healthCheckUrl="http://${ip_2}:${port}${health_check_url}"
                    script {
                        def responseCode = sh(script: "curl -o /dev/null -w '%{http_code}' ${healthCheckUrl}", returnStdout: true).trim()
                        if (responseCode != '200') {
                            // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 배포실패 branch: ${BRANCH}")
                        }
                    }
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] application health check fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("aws target register 2") {
                try {
                    sh "aws elbv2 register-targets --target-group-arn ${target_group_arn} --targets Id=${instance_id_2},Port=${port}"
                    sleep 20
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] load balancing taget regist fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("aws target health check 2") {
                try {
                    sh "aws elbv2 describe-target-health --target-group-arn ${target_group_arn} --targets Id=${instance_id_2},Port=${port} | jq '.TargetHealthDescriptions[0].TargetHealth.State' > ~/loadbalencingHealthCheckResult.txt"
                    // sh "~/loadbalencingHealthCheck.sh"
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] load balancing health check fail branch: ${BRANCH}")
                    throw e
                }
            }
            stage("success noti") {
                try {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#008cff', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 배포성공 branch: ${BRANCH}")
                } catch (Exception e) {
                    // slackSend(channel: '#dev-laundry24-build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 배포실패 branch: ${BRANCH}")
                    throw e
                }
            }
            }
        }
    }

살펴볼 내용은 아래와 같다.
1. aws target group derigister, register
2. 구동중인 server PID를 찾고 server kill
3. server healthcheck

aws target group derigister, register

aws-cli명령어를 통해 derigister, register를 하도록 하였다.

// derigister
aws elbv2 deregister-targets --target-group-arn ${target_group_arn} --targets Id=${instance_id_1},Port=${port}

// register
aws elbv2 register-targets --target-group-arn ${target_group_arn} --targets Id=${instance_id_1},Port=${port}

구동중인 server PID를 찾고 server kill

// Get the PID from the remote server and store it in a variable
def command = "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_2} sudo lsof -ti tcp:${port}"
def PID = sh(script: command, returnStdout: true).trim()
// Echo the PID
echo "PID: ${PID}"
// kill 
sh "ssh -i /var/lib/jenkins/laundrygo_was.pem ec2-user@${ip_2} kill -15 ${PID}"

lsof -ti tcp:${port}

위 lsof 명령어로 port를 통해 해당 서버의 PID를 알아낼 수 있다.

kill -15 ${PID}

이후 알아낸 PID를 Kill한다. Kill 명령어는 graceful shutdown을 위해 -9를 사용하지 않아야 한다.
간략하게 linux kill에 대해 알아보자.

$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2

-9(SIGKILL) : 프로세스를 즉시 종료. 처리중이던 작업들의 유무에 관계 없이 즉시 종료한다.
-15(SIGTERM) : 프로세스를 정상적으로 종료시킨다. 소프트웨어 프로세스에게 종료하라는 시그널을 준다고 생각하면 된다.

따라서 graceful shutdown을 위해 -15 옵션으로 kill을 해야 한다. kill -15 명령어가 실행되면 property 파일에 graceful shutdown을 설정해 두었다면 Spring의 SpringApplicationShutdownHook 이라는 객체를 통해 Spring을 종료시키기 시작한다.

server healthcheck

healthCheckUrl="http://${ip_1}:${port}${health_check_url}"
script {
  def responseCode = sh(script: "curl -o /dev/null -w '%{http_code}' ${healthCheckUrl}", returnStdout: true).trim()
  if (responseCode != '200') {
	slackSend(channel: '#build-notification', color: '#ff002b', message: "${env.JOB_NAME} [${env.BUILD_NUMBER}] 배포실패 branch: ${BRANCH}")
  }
}

health check는 health check를 위한 API를 해당 server에 만들고 http status가 200으로 떨어지면 정상적으로 통과했다고 간주했다.

Conclusion

서버 이중화 작업을 통해 좀 더 안정성있는 서비스를 만들어 보는 경험을 하였다.
기존 구조에서 좀 더 개선해보면 좋을 점이 있는데 아래와 같다.

  1. docker image화
    현재의 구조로는 서버가 추가되면 해당 서버의 세팅을 따로 또 해두어야 한다. 서버가 구동되는데 필요한 것들을 docker image로 만들어 실행시키면 더 효과적일 것이라고 생각된다.
  2. deploy.sh를 통해 jenkins pipeline의 ssh 명령어 제거
    현재는 pipeline을 통해 모든 명령을 ssh로 요청하고 있다. ssh를 요청하는 것 자체가 다른 서버와 연결을 맺는 것인데 이것 자체가 비용이라고 생각이 들어 한번 연결을 맺고 deploy.sh 스크립트를 실행시키는 것이 더 효율적이라는 생각이 든다.
  3. 해당 서비스 EKS화
    사실 해당 서비스는 트래픽이 많은 서비스가 아니고 앞으로 서버가 증설될 확률이 적다고 보지만 AWS 비용절감과 관리 측면에서 EKS화를 하는 것이 좋을 것이라 판단된다.

0개의 댓글