시작하기
예전에는 배포하는 날이면 접속하는 사용자가 적은 새벽시간에 했다고 본적이 있습니다. 배포를 할 때에는 새로운 버전의 jar 파일을 배포할 서버로 복사시키고, 직접 SSH로 접속하여 jar를 변경하여 실행하는 것이였습니다.
이렇게 배포를 진행하면, 배포를 할 때마다 개발자의 많은 리소스가 들어간다는 단점과 jar를 바꿔끼는 그 찰나에 서비스가 끊긴다는 치명적인 단점이 존재합니다.
그래서 이번 시간에는 어떻게 하면 배포를 자동화하고, 또 기존 서비스를 정지시키지 않고 배포할 수 있는지 알아보도록 하겠습니다.
위에서 말한 이 무중단 배포에는 여러 방법들이 있습니다.
이번 시간에는 하나의 서버가 있다고 가정하고, 내부에 Nginx와 Docker를 구성하여 무중단 배포를 해보겠습니다.
0. 전체 구성도
프로세스는 다음과 같습니다.
- Gitlab에 특정 브랜치로 푸시 이벤트가 발생하면, Jenkins로 Trigger를 날리게 됩니다.
- 젠킨스에서는 GitLab에서 최신코드를 받아와서 빌드하고 테스트를 수행하여 도커 이미지를 생성하여 Docker 저장소로 Push 합니다.
- 젠킨스에서 배포 서버로 커맨드를 통해 새로운 버전의 서버를 배포합니다.
사전 설정
- Jenkins 설정
- GitLab 설정
1. 플러그인 다운로드
먼저 배포를 위한 아래 플러그인들을 다운받아주도록 합니다. 깃랩, 도커, SSH 를 위한 플러그인들 입니다.
docker-build-step
This plugin allows to add various docker commands to your job as build steps.
plugins.jenkins.io
SSH Agent
This plugin allows you to provide SSH credentials to builds via a ssh-agent in Jenkins.
plugins.jenkins.io
Generic Webhook Trigger
Can receive any HTTP request, extract any values from JSON or XML and trigger a job with those values available as variables. Works with GitHub, GitLab, Bitbucket, Jira and many more.
plugins.jenkins.io
GitLab
This plugin allows <a href="http://gitlab.com/" target="_blank" rel="nofollow noopener noreferrer">GitLab</a> to trigger Jenkins builds and display their results in the GitLab UI.
plugins.jenkins.io
플러그인 설정
[Jenkins Gitlab 연동 - 추후 포스팅]
[Jenkins SSH 설정하기- 추후 포스팅]
[JDK 설정하기- 추후 포스팅]
[Docker 설정하기- 추후 포스팅]
Global Properties 설정
Dashboard → Jenkins 관리 → Configure System → Global properties → Environment variables 추가
- 배포 서버 URL
- 도커 저장소 URL
- …
Credentials 추가하기
Dashboard → Jenkins 관리 → Credentials → System → Global credentials (unrestricted) → +Add Credentials
ID는 Jenkins에서 사용할 변수 이름입니다.
- 깃랩
id/pw
- docker
id/pw
- 추가
2. Pipeline 생성
New Item → Pipeline 생성
- Build Triggers 설정하기 (추후 포스팅 예정)
Pipeline 스크립트 작성
파이프라인에는 여러 스테이지로 구성됩니다. 스테이지는 아래와 같이 구성하겠습니다.
- GitLab 최신 코드 Clone
- Gradle 프로젝트 빌드 (테스트 및 JAR 생성)
- Docker Image Build
- Docker Image Push
- 배포 서버로 명령어를 통해 새로운 버전 배포
GitLab 최신 코드 Clone
stage ('git clone') {
steps {
git branch: 'main', credentialsId: 'gitlab-credentials', url: 'https://git.example.com/myapp.git'
}
post {
failure {
echo 'Repository clone failure !'
}
success {
echo 'Repository clone success !'
}
}
}
이번 Stage에서는 이전에 설정해둔 gitlab credentials 정보를 가지고 URL로 Clone을 받는 코드입니다. Clone할 Branch명과 crendentials, URL이 잘 설정되어있는지 확인하시길 바랍니다.
Gradle 프로젝트 빌드 (테스트 및 JAR 생성)
stage('gradle build') {
steps {
sh 'chmod +x gradlew'
sh './gradlew build'
}
post {
failure {
echo 'Gradle build failure!'
}
success {
echo 'Gradle build success!'
}
}
}
이번 Stage에서는 프로젝트 루트에 있는 gradlew 파일에 대한 실행 권한을 부여하고, 빌드를 실행합니다. 만약 테스트가 실패한다면 이후 Stage들은 모두 실행되지 않습니다.
Docker Image Build
environment {
dockerHubRegistry = 'https://example.dockerhub.com'
dockerRepository = 'example.dockerhub.com/myapp'
}
stage('Docker Image Build') {
steps {
sh "docker build -t ${dockerRepository}:${currentBuild.number} ."
sh "docker build -t ${dockerRepository}:latest ."
sh "docker rmi ${dockerRepository}:${currentBuild.number}"
}
post {
failure {
echo 'Docker image build failure!'
}
success {
echo 'Docker image build success!'
}
}
}
이번에는 environment를 이용해 dockerHubRegistry와 dockerRepository를 환경변수로 등록하여 사용했습니다. 도커 빌드 명령어를 이용하여 이미지를 생성하는 코드입니다.
Docker Image Build
stage('Docker Image Push') {
steps {
withCredentials([[$class: 'UsernamePasswordMultiBinding',
credentialsId: 'dockerhub-credentials',
usernameVariable: 'DOCKER_USER_ID',
passwordVariable: 'DOCKER_USER_PASSWORD'
]]){
sh 'echo ${DOCKER_USER_PASSWORD} | docker login -u ${DOCKER_USER_ID} --password-stdin ${dockerHubRegistry}'
sh "docker push ${dockerRepository}:latest"
}
}
}
이번 Stage에서는 젠킨스 내부에 내장된 UsernamePasswordMultiBinding을 이용하여 이전에 등록해두었던 Jenkins 관련 변수들을 불러오도록 합니다. 도커 로그인을 해주고, 생성했던 이미지를 저장소로 푸시하는 작업을 수행합니다.
배포 단계
stage('Server Docker Run') {
steps {
sshagent (credentials: ['jenkins-rsa']) {
sh '''
#!/bin/bash
if curl -s "${blue_url}" > /dev/null
then
deployment_container_name=green
deployment_target_ip=$green_url
deployment_port=$green_port
old_container_name=blue
else
deployment_container_name=blue
deployment_target_ip=$blue_url
deployment_port=$blue_port
old_container_name=green
fi
ssh root@${deploy_ip} "nohup docker run --name ${deployment_container_name} -p ${deployment_port}:8080 ${dockerRepository}:latest > /dev/null &" &
for retry_count in \$(seq 10)
do
if curl -s "${deployment_target_ip}" > /dev/null
then
echo "서버 Health Check에 성공했습니다."
break
fi
if [ $retry_count -eq 10 ]
then
echo "서버 Health Check에 실패했습니다."
exit 1
fi
echo "서버 Health Check를 10초 이후에 재시도합니다..."
sleep 10
done
ssh root@${deploy_ip} "echo 'set \\\$service_url ${deployment_target_ip};' > /root/docker-compose/nginx/nginx/conf.d/service-url.inc"
ssh root@${deploy_ip} "docker exec -i nginx service nginx reload"
echo "Nginx Reverse Proxy 변경: ${deployment_target_ip}"
echo "기존 도커 서버 종료"
ssh root@${deploy_ip} "docker rm -f ${old_container_name}"
'''
}
}
}
이전에 설치했던 SSH Agent를 이용하여 배포 서버에 등록시킨 RSA 개인키를 불러와서 배포 서버로 명령어를 전송시킵니다. 아래에서 자세하게 코드를 살펴보겠습니다. (스크립트 내부에서 사용된 변수들은 Jenkins Global Variable로 저장시켰습니다.)
if curl -s "${blue_url}" > /dev/null
then
deployment_container_name=green
deployment_target_ip=$green_url
deployment_port=$green_port
old_container_name=blue
else
deployment_container_name=blue
deployment_target_ip=$blue_url
deployment_port=$blue_port
old_container_name=green
fi
먼저 배포 서버에 Blue IP에 요청을 날려보고 해당 서버에 응답이 있으면 Blue 서버가 Old가 되는 것입니다. 만약 서버에 응답이 없다면 해당 Blue가 New가 되는 것입니다.
ssh ${deploy_username}@${deploy_ip} "nohup docker run --name ${deployment_container_name} -p ${deployment_port}:8080 ${dockerRepository}:latest > /dev/null &" &
이제 배포 서버로 저희가 만든 최신 버전의 도커 이미지를 실행하도록 명령어를 실행합니다. 마지막에 > /dev/null & 는 출력을 버리겠다는 의미입니다.
이제 새로 배포한 서버가 실제로 잘 떴는지 확인해봐야겠죠?
for retry_count in \$(seq 10)
do
if curl -s "${deployment_target_ip}" > /dev/null
then
echo "서버 Health Check에 성공했습니다."
break
fi
if [ $retry_count -eq 10 ]
then
echo "서버 Health Check에 실패했습니다."
exit 1
fi
echo "서버 Health Check를 10초 이후에 재시도합니다..."
sleep 10
done
배포한 서버에 Curl 요청을 날려 잘 실행되었는지 체크합니다. 만약 10번동안 Health Check에 실패하면 해당 Stage는 실패하게 됩니다.
ssh ${deploy_username}@${deploy_ip} "echo 'set \\\$service_url ${deployment_target_ip};' > /etc/nginx/conf.d/service-url.inc"
ssh ${deploy_username}@${deploy_ip} "docker exec -i nginx service nginx reload"
echo "Nginx Reverse Proxy 변경: ${deployment_target_ip}"
echo "기존 도커 서버 종료"
ssh ${deploy_username}@${deploy_ip} "docker rm -f ${old_container_name}"
배포된 서버의 Health Check에 성공하였다면, 이제 Nginx의 프록시 방향을 새로운 서버로 변경해야 합니다. 배포한 서버 IP 정보를 업데이트하고 Nginx를 reload 시킵니다. 그리고 기존에 살아있던 구버전 도커 컨테이너를 종료 및 삭제 시키도록 합니다.
전체 스크립트
pipeline {
agent any
tools {
jdk "openjdk-11"
}
environment {
dockerHubRegistry = 'https://example.dockerhub.com'
dockerRepository = 'example.dockerhub.com/myapp'
}
stages {
stage ('git clone') {
steps {
git branch: 'main', credentialsId: 'gitlab-credentials', url: 'https://git.example.com/myapp.git'
}
post {
failure {
echo 'Repository clone failure !'
}
success {
echo 'Repository clone success !'
}
}
}
stage('gradle build') {
steps {
sh 'chmod +x gradlew'
sh './gradlew build'
}
post {
failure {
echo 'Gradle build failure!'
}
success {
echo 'Gradle build success!'
}
}
}
stage('Docker Image Build') {
steps {
sh "docker build -t ${dockerRepository}:${currentBuild.number} ."
sh "docker build -t ${dockerRepository}:latest ."
sh "docker rmi ${dockerRepository}:${currentBuild.number}"
}
post {
failure {
echo 'Docker image build failure!'
}
success {
echo 'Docker image build success!'
}
}
}
stage('Docker Image Push') {
steps {
withCredentials([[$class: 'UsernamePasswordMultiBinding',
credentialsId: 'dockerhub-credentials',
usernameVariable: 'DOCKER_USER_ID',
passwordVariable: 'DOCKER_USER_PASSWORD'
]]){
sh 'echo ${DOCKER_USER_PASSWORD} | docker login -u ${DOCKER_USER_ID} --password-stdin ${dockerHubRegistry}'
sh "docker push ${dockerRepository}:latest"
}
}
}
stage('Server Docker Run') {
steps {
sshagent (credentials: ['jenkins-rsa']) {
sh '''
#!/bin/bash
if curl -s "${blue_url}" > /dev/null
then
deployment_container_name=green
deployment_target_ip=$green_url
deployment_port=$green_port
old_container_name=blue
else
deployment_container_name=blue
deployment_target_ip=$blue_url
deployment_port=$blue_port
old_container_name=green
fi
ssh ${deploy_username}@${deploy_ip} "nohup docker run --name ${deployment_container_name} -p ${deployment_port}:8080 ${dockerRepository}:latest > /dev/null &" &
for retry_count in \$(seq 10)
do
if curl -s "${deployment_target_ip}" > /dev/null
then
echo "서버 Health Check에 성공했습니다."
break
fi
if [ $retry_count -eq 10 ]
then
echo "서버 Health Check에 실패했습니다."
exit 1
fi
echo "서버 Health Check를 10초 이후에 재시도합니다..."
sleep 10
done
ssh ${deploy_username}@${deploy_ip} "echo 'set \\\$service_url ${deployment_target_ip};' > /root/docker-compose/nginx/nginx/conf.d/service-url.inc"
ssh ${deploy_username}@${deploy_ip} "docker exec -i nginx service nginx reload"
echo "Nginx Reverse Proxy 변경: ${deployment_target_ip}"
echo "기존 도커 서버 종료"
ssh root@${deploy_ip} "docker rm -f ${old_container_name}"
'''
}
}
}
}
}
이렇게 GitLab, Jenkins, Docker, Nginx를 이용하여 무중단 배포를 해보았습니다. Gitlab에서 기본적으로 CI/CD에 대한 기능을 지원해주기도 하지만 Jenkins를 사용해보고 싶어서 실습 해보았습니다.
감사합니다.
Trouble Shooting
- the input device is not a TTY해결 방법
- 간혹가다 bash shell script나 ssh, docker에서 -t 옵션을 준 다음 <<<을 통해 input값을 넘겨주는 경우 pseudo-TTY에러가 자주 뜨는 것을 알 수 있다. 그 이유는 -i옵션으로 input을 -t옵션으로 인해 interface driver가 tty(stdin/stdout의 상위)로 실행되어야하는데 input pipe가 들어오니, 이럴때는 t옵션을 주지 않으면 된다.
$ ssh root@192.168.0.100 "docker exec -it nginx service nginx reload" the input device is not a TTY
- linux redirection > permission denied
- ssh로 명령어를 실행시킬 때, 명령어를 꼭 “”로 감싸줘야 한다. 안그러면 내부에서 실행하기 때문에 원하는 실행 결과를 예상할 수 없다.
- ERROR: JAVA HOME is set to an invalid directory
REFERENCES
[Jenkins] # 선언적(Declarative) 파이프라인
들어가기 앞서 다시한번 선언적 문법 파이프라인의 중요한 특징을 복기해보자.그루비 프로그래밍을 흉내낼 필요가 없다.더 적합한 검증과 에러확인이 가능하다.선언적 파이프라인은 큰 하나의
velog.io
Jenkins 로 도커 이미지 Build & Push 자동화하기
Kubeflow에는 kfp라는 Python SDK가 존재하며 이를 통해 도커 이미지 없이 ML pipeline을 구축할 수 있다. 하지만 딥러닝을 수행할 경우 코드 길이가 길어져 kfp로 코드 작성 및 수정이 어려워지고 큰 데이
blog.kubwa.co.kr