현재 운영 중인 서비스중 현재 단일 서버, 중단배포 방식으로 배포가 이루어지는 서비스가 있다는 사실을 알게 됐고 이를 문제라고 생각해 서버 이중화 작업을 하기로 결정했다. 그 기록을 글로 남기고자 한다.
우선 단일 서버, 중단 배포 방식이 문제가 되는 이유는
해당 서비스는 현재 밤, 낮 할 것 없이 24시간 운영되는 서비스여서 지금처럼 운영된다면 추후에 문제가 발생할 가능성이 농후하다고 판단하였다.
우선 기존의 인프라 구조도를 대략적으로 살펴보면 아래와 같다. 하나의 EC2 서버에 운영중인 서버 4대가 4개의 포트에 각각 띄워져 있고 Load Balancer가 prefix url을 통해 각각의 요청을 적합한 서버로 포워딩해주고 있다.
첫 번째로 해야 할 작업은 새로운 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 배포방식은 위와 같고 이를 아래과 같이 수정하기로 결정했다.
혹시나 실수가 발생할 수 있기 때문에 build와 deploy pipeline은 각각 나누고 bitbucket에 걸려있던 webhook을 제거하고 jenkins UI의 버튼을 누를 시에 trigger가 발생하도록 수정하였다.
build
deploy
바뀐 부분의 핵심은 target group에서 배포가 이루어질 서버를 한 대씩 deregister하고 서버 배포후, 문제가 없으면 register하는 것이라고 볼 수 있다. 또한 롤링배포 방식을 통해 무중단 배포방식을 도입하였다.
target group deregister를 하는 이유는 배포를 하는 동안 정상동작하지 않을 해당 서버로 트래픽이 가면 안되기 때문이다.
그럼 이제 적성한 pipeline을 알아보자. pipeline은 groovy 문법으로 작성되었다.
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
}
}
}
}
}
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-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}
// 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을 종료시키기 시작한다.
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으로 떨어지면 정상적으로 통과했다고 간주했다.
서버 이중화 작업을 통해 좀 더 안정성있는 서비스를 만들어 보는 경험을 하였다.
기존 구조에서 좀 더 개선해보면 좋을 점이 있는데 아래와 같다.