DevOps/CI CD

Docker + Nginx + Jenkins CI / CD 무중단 배포

Jeffrey Oh 2023. 3. 20. 13:16
반응형

Blue / Green 전략으로 무중단 배포 하기

  • Docker
  • Nginx
  • Jenkins
  • Spring
  • Intellij

Docker 설치

docker 와 docker compose 설치를 먼저 해야한다.
다음 링크를 확인하여 기준에 맞게 설치하자


Nginx 설치

아래 링크를 확인하여 설치 및 설정


Docker compose 작성

blue, green 배포 전략이기 때문에 yml 은 2개가 필요하다

docker-compose.blue.yml

sudo vi docker-compose.blue.yml
version: '3.1'

services: 
  api:
    image: ${IMAGE_STORAGE}/${IMAGE_NAME}:${BUILD_NUMBER}
    container_name: ${IMAGE_NAME}-blue
    environment:
      - LANG=ko_KR.UTF-8
      - UWSGI_PORT=8080
      - TZ=Asia/Seoul
    ports:
      - '8080:8080'

networks:
  default:
    external:
      name: service-network

docker-compose.green.yml

sudo vi docker-compose.green.yml
version: '3.1'

services: 
  api:
    image: ${IMAGE_STORAGE}/${IMAGE_NAME}:${BUILD_NUMBER}
    container_name: ${IMAGE_NAME}-green
    environment:
      - LANG=ko_KR.UTF-8
      - UWSGI_PORT=8081
      - TZ=Asia/Seoul
    ports:
      - '8081:8080'

networks:
  default:
    external:
      name: service-network

포워딩은 아래와 같다

  • blue: 8080
  • green: 8081

IMAGE_STORAGE, IMAGE_NAME, BUILD_NUMBER 3개의 변수는 Jenkins에서 환경변수로 지정할 예정

두 컨테이너에서 사용할 docker network도 생성

docker network create service-network

배포 스크립트 작성

sudo vi deploy.sh

{user} 계정명 경로 사용
예) /home/ec2-user

/etc/nginx/sites-available/domain.com.conf 내용 수정 필요

# 1
EXIST_BLUE=$(docker-compose -p ${IMAGE_NAME}-blue -f docker-compose.blue.yml ps | grep Up)

if [ -z "$EXIST_BLUE" ]; then
    docker-compose -p ${IMAGE_NAME}-blue -f /home/{user}/docker-compose.blue.yml up -d
    BEFORE_COMPOSE_COLOR="green"
    AFTER_COMPOSE_COLOR="blue"
    BEFORE_PORT_NUMBER=8081
    AFTER_PORT_NUMBER=8080
else
    docker-compose -p ${IMAGE_NAME}-green -f /home/{user}/docker-compose.green.yml up -d
    BEFORE_COMPOSE_COLOR="blue"
    AFTER_COMPOSE_COLOR="green"
    BEFORE_PORT_NUMBER=8080
    AFTER_PORT_NUMBER=8081
fi

echo -e "\n${AFTER_COMPOSE_COLOR} server up(port:${AFTER_PORT_NUMBER})"

# 2
for cnt in {1..10}
do
    echo "서버 응답 확인중..(${cnt}/10)";
    UP=$(curl -s http://localhost:${AFTER_PORT_NUMBER}/actuator/health | grep 'UP')
    if [ -z "${UP}" ] 
        then
        sleep 10
        continue       
        else
            break
    fi
done

if [ $cnt -eq 10 ]
then
    echo "서버가 정상적으로 구동되지 않았습니다."
    exit 1
fi

# 3
sudo sed -i "s/${BEFORE_PORT_NUMBER}/${AFTER_PORT_NUMBER}/" /etc/nginx/sites-available/domain.com.conf
sudo nginx -s reload
echo "Deploy Completed!!"

# 4
echo "$BEFORE_COMPOSE_COLOR server down(port:${BEFORE_PORT_NUMBER})"
docker-compose -p ${IMAGE_NAME}-${BEFORE_COMPOSE_COLOR} -f docker-compose.${BEFORE_COMPOSE_COLOR}.yml down

# 5
echo "y" | docker container prune

# 6
echo "y" | docker image prune

# 7
echo "y" | docker volume prune

# 8
echo "y" | docker system prune -a
  • 2 번 과정에서 actuator/healthSpring 에서 health check 하기 위한 용도로 사용한 actuator 라이브러리이며
    타 언어나 프레임워크마다 health check 하는 방법으로 대체하면 됨
  • 2 번 과정에서 localhostubuntu 인 경우 127.0.0.1 로 해야함
  • 3 번 과정에서 sed -i "" 파일명 을 하는 것은 포트를 변환하기 위한 작업
  • 3 번 과정에서 포트변환이 일어났기 때문에 방화벽에서 8081 가 막혀서 실행이 안된다면 추가해주자
    Naver Cloud Platform 에서는 ACG 설정 하면된다
  • 4 번 과정에서 새로 실행한 컨테이너가 정상인 것을 확인 하였기 때문에 기존에 사용하던 컨테이너를 중단시킨다
  • 5 ~ 8 번 과정에서 미사용하는 데이터를 삭제한다

만약 latest 로만 처리하려면 항상 새로 pull 받기 위해 아래 처럼 1번 과정을 수정한다

# 1
EXIST_BLUE=$(docker-compose -p ${IMAGE_NAME}-blue -f docker-compose.blue.yml ps | grep Up)

if [ -z "$EXIST_BLUE" ]; then
    docker-compose -f /home/{user}/docker-compose.blue.yml pull && docker-compose -p ${IMAGE_NAME}-blue -f /home/{user}/docker-compose.blue.yml up -d
    BEFORE_COMPOSE_COLOR="green"
    AFTER_COMPOSE_COLOR="blue"
    BEFORE_PORT_NUMBER=8081
    AFTER_PORT_NUMBER=8080
else
    docker-compose -f /home/{user}/docker-compose.blue.yml pull && docker-compose -p ${IMAGE_NAME}-green -f /home/{user}/docker-compose.green.yml up -d
    BEFORE_COMPOSE_COLOR="blue"
    AFTER_COMPOSE_COLOR="green"
    BEFORE_PORT_NUMBER=8080
    AFTER_PORT_NUMBER=8081
fi

echo -e "\n${AFTER_COMPOSE_COLOR} server up(port:${AFTER_PORT_NUMBER})"

docker-compose -f /home/{user}/docker-compose.blue.yml pull && 앞의 명령어를 추가


Container Registry 에서 이미지를 pull 받으려면 docker 에 로그인 필요

docker login {Container Registry 주소}

Naver Cloud Platform 에서 사용하는 경우 로그인 ID, PW 는 root 계정의 API public key, secret key 가 순서대로 ID, PW 이다


배포 수동 테스트

수동 테스트를 위한 *.jar 또는 *.war 파일을 서버에 옮기고 (파일질라 등을 이용)
Dockerfile 을 아래처럼 작성

FROM amazoncorretto:11-alpine-jdk

EXPOSE 8080

COPY *.war app.jar
CMD java -jar app.jar

추후 프로젝트 내에서 파일을 추가할 때 COPY 는 다음 처럼 수정

FROM amazoncorretto:11-alpine-jdk

EXPOSE 8080

COPY ./build/lib/*.war 프로젝트명.war
CMD java -jar 프로젝트명.war

war 말고 jar 면 jar 확장자로 수정하고 jdk 버전은 프로젝트에서 호환되는 것에 맞게 지정


만약 프로젝트에서 spring profile 도 구분하거나 spring cloud config 를 사용하는 경우 아래처럼 환경변수를 모두 보내주어야함

아래 내용은 프로젝트 내에서 Dockerfile 예시

FROM amazoncorretto:11-alpine-jdk

EXPOSE 8080

ARG SPRING_PROFILES_ACTIVE
ARG CONFIG_SERVER_ENCRYPT_KEY
ARG CONFIG_SERVER_USERNAME
ARG CONFIG_SERVER_PASSWORD

ENV SPRING_PROFILES_ACTIVE=$SPRING_PROFILES_ACTIVE
ENV CONFIG_SERVER_ENCRYPT_KEY=$CONFIG_SERVER_ENCRYPT_KEY
ENV CONFIG_SERVER_USERNAME=$CONFIG_SERVER_USERNAME
ENV CONFIG_SERVER_PASSWORD=$CONFIG_SERVER_PASSWORD

COPY ./build/libs/*.war 프로젝트명.war
CMD java -jar ./프로젝트명.war

다시 서버로 돌아가서 이미지 파일을 build 하고 Registry 로 push 한다.

build 단계에서 Dockerfile 과 COPY 하는 경로는 동일해야한다.

docker build -t {Container Registry 주소}/{Image Name} .
// docker build -t {Container Registry 주소}/{Image Name} --build-arg SPRING_PROFILES_ACTIVE=dev .

docker push {Container Registry 주소}/{Image Name}

2번째 주석은 build 할 때 이미지에서 환경변수를 사용해야하는 경우에는 직접 매개변수를 넘겨줘야 한다.

수동 테스트 시에 필요한 이미지는 외부에서 직접 보내주는게 아니기 때문에 (로컬 파일에서 부르는 것도 아님) build 할 때 변수를 직접 넘겨줘야한다.

# 환경변수 등록
export IMAGE_STORAGE={Container Registry 주소};
export IMAGE_NAME={Image Name};
export BUILD_NUMBER={Image Tag};

# 읽기, 실행 권한 부여
sudo chmod 755 deploy.sh
sudo chmod 755 docker-compose.blue.yml
sudo chmod 755 docker-compose.green.yml

# 스크립트 실행
./deploy.sh

deploy.sh 를 실행할 때 같은 경로에 Dockerfile, *.war 2개가 같이 있어야한다.

테스트 이후에는 2개의 파일(Dockerfile, *.war)을 삭제해도 됨

이 글을 읽고 또 구축하는 단계에서 수동 테스트가 제대로 되지 않은 경우 꼭 여기에 업데이트 글을 넣자 !!! 까먹지말라 !!


나중에 Jenkins 에서 이용할 환경변수로 사용될 것이기 때문에 프로필 파일에 추가해둔다

sudo vi /home/{user}/.bashrc

만약 .bash_profile 이 있다면 거기에 해도 무방

파일을 수정권한을 주고 최하단에 아래와 같이 작성

export IMAGE_STORAGE={Container Registry 주소}
export IMAGE_NAME={Image Name}
export BUILD_NUMBER={Image Tag}

파일을 저장하고 나와서 다음을 실행하여 업데이트 한다

sudo source /home/{user}/.bashrc

Jenkins 파이프라인 구축

(업데이트 2023-07-14)
AWS ECR + Jenkins CI CD 구축은 여기로

  1. 소스 Push
  2. Jenkins Webhook
  3. Application Test, Build
  4. Docker image Build
  5. Docker image Push
  6. ssh 배포할 서버에 ssh로 쉘 명령어 실행(./deploy.sh)

Jenkinsfile 작성

아래 파이프라인에는 slack notification webhook 도 추가되어있다.

pipeline {
  agent any

  tools {
    jdk("java-11-amazon-corretto")
  }

  environment {
    SLACK_CHANNEL = "#채널명"
    SLACK_SUCCESS_COLOR = "#2C953C";
    SLACK_FAIL_COLOR = "#FF3232";
  }

  stages {

    stage('Set Environment') {
      post {
        success {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_SUCCESS_COLOR, message: "==================================================================\n배포 파이프라인이 시작되었습니다.\n${JOB_NAME}(${BUILD_NUMBER})\n${GIT_COMMIT_AUTHOR} - ${GIT_COMMIT_MESSAGE}\n${BUILD_URL}")
        }
      }
      steps {
        script {
          switch(BRANCH_NAME) {
            case '브랜치명-dev' :
              env.IMAGE_NAME = '프로젝트명'
              env.IMAGE_STORAGE = '서버에 전달할 Container Registry 주소'
              env.IMAGE_STORAGE_CREDENTIAL = 'Jenkins 에서 사용할 로그인 정보 파일명'
              env.SSH_CONNECTION = 'SSH 접속정보 예) ncloud@123.112.121.221'
              env.SSH_CONNECTION_CREDENTIAL = 'Jenkins 에서 사용할 SSH 로그인 정보 파일명'
              break
            //case '브랜치명-prod' :
            //  env.IMAGE_NAME = '프로젝트명'
            //  env.IMAGE_STORAGE = '서버에 전달할 Container Registry 주소'
            //  env.IMAGE_STORAGE_CREDENTIAL = 'Jenkins 에서 사용할 로그인 정보 파일명'
            //  env.SSH_CONNECTION = 'SSH 접속정보 예) ncloud@123.112.121.222'
            //  env.SSH_CONNECTION_CREDENTIAL = 'Jenkins 에서 사용할 SSH 로그인 정보 파일명'
            //  break
          }
          GIT_COMMIT_AUTHOR = sh(script: "git --no-pager show -s --format=%an ${GIT_COMMIT}", returnStdout: true).trim();
          GIT_COMMIT_MESSAGE = sh(script: "git --no-pager show -s --format=%B ${GIT_COMMIT}", returnStdout: true).trim();
        }
      }
    }

    stage('Clean Build Test') {
      post {
        success {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_SUCCESS_COLOR, message: 'Build Test에 성공하였습니다.')
        }
        failure {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_FAIL_COLOR, message: 'Build Test에 실패하였습니다.\n==================================================================')
        }
      }
      steps {
        sh "SPRING_PROFILES_ACTIVE=${BRANCH_NAME} ./gradlew clean build test"
        // 아래는 멀티모듈 환경인 경우
        // sh "SPRING_PROFILES_ACTIVE=${BRANCH_NAME} ./gradlew :app-api:clean :app-api:build :app-api:test"
      }
    }

    stage('Build Container Image') {
      post {
        success {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_SUCCESS_COLOR, message: 'Container Image Build에 성공하였습니다.')
        }
        failure {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_FAIL_COLOR, message: 'Container Image Build에 실패하였습니다.\n==================================================================')
        }
      }
      steps {
        script {
          image = docker.build("${IMAGE_STORAGE}/${IMAGE_NAME}", ".")
          // SPRING_PROFILES_ACTIVE - spring 프로필을 나누는 경우 사용
          // CONFIG_SERVER_* - spring cloud config 사용하는 경우 사용
          // image = docker.build("${IMAGE_STORAGE}/${IMAGE_NAME}", "--build-arg SPRING_PROFILES_ACTIVE=${BRANCH_NAME} --build-arg CONFIG_SERVER_ENCRYPT_KEY=${CONFIG_SERVER_ENCRYPT_KEY} --build-arg CONFIG_SERVER_USERNAME=${CONFIG_SERVER_USERNAME} --build-arg CONFIG_SERVER_PASSWORD=${CONFIG_SERVER_PASSWORD} .")
          // 멀티 모듈인 경우 프로젝트명 기준으로 Jenkins 파일 위치를 지정
          // image = docker.build("${IMAGE_STORAGE}/${IMAGE_NAME}", "./app-api")
        }
      }
    }

    stage('Push Container Image') {
      post {
        success {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_SUCCESS_COLOR, message: 'Container Image Push에 성공하였습니다.')
        }
        failure {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_FAIL_COLOR, message: 'Container Image Push에 실패하였습니다.\n==================================================================')
        }
      }
      steps {
        script {
          docker.withRegistry("https://${IMAGE_STORAGE}", IMAGE_STORAGE_CREDENTIAL) {
            image.push("${BUILD_NUMBER}")
            image.push("latest")
          }
        }
      }
    }

    stage('Remove Container Image') {
      steps {
        sh "docker image rm ${IMAGE_STORAGE}/${IMAGE_NAME}:latest"
        sh "docker image rm ${IMAGE_STORAGE}/${IMAGE_NAME}:${BUILD_NUMBER}"
      }
    }

    stage('Server Run') {
      post {
        success {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_SUCCESS_COLOR, message: '배포에 성공하였습니다.\n==================================================================')
        }
        failure {
          slackSend(channel: SLACK_CHANNEL, color: SLACK_FAIL_COLOR, message: '배포에 실패하였습니다.\n==================================================================')
        }
      }
      steps {
        sshagent(credentials: [SSH_CONNECTION_CREDENTIAL]) {
            sh "ssh -o StrictHostKeyChecking=no ${SSH_CONNECTION} 'IMAGE_NAME=${IMAGE_NAME} IMAGE_STORAGE=${IMAGE_STORAGE} BUILD_NUMBER=${BUILD_NUMBER} ./deploy.sh'"
        }
      }
    }
  }

}

에러 대응

+ scp -r folder user@ip:/path
Host key verification failed.
lost connection
script returned exit code 1

만약 위같은 에러가 뜬다면 scp 명령어에 다음을 추가한다.

-o StrictHostKeyChecking=no

완성된 부분은 아래와 같다.

scp -o StrictHostKeyChecking=no -r folder user@ip:/path
반응형