Infra/Docker

CI/CD 구축하기 (with Jenkins, Docker, Springboot)

dundun213 2024. 6. 30. 16:56

 

 

DateFilm Project Series 글 목록

 

 

 

 

💡 CD란 무엇이고, 구축하는 이유

1. CD 란 ?

  • CD는 Continuous Delivery 또는 Continuous Deployment를 의미하며, 소프트웨어를 자동으로 빌드, 테스트, 배포하는 과정을 지속적으로 수행하는 것

2. CD 를 구축하는 목적

  • 기능을 완성할때마다 발생하는 프로젝트 빌드, 배포를 자동화함으로써 개발자는 개발에만 집중 할 수 있게 도와줍니다.

 


 

🌏 전체적인 구조

이번에 CD 를 구축하는 흐름은 다음과 같습니다.

  1. GitHub Repository 에 Merge 발생
  2. Jenkins가 Webhook 전달 받아 git pull
  3. 비밀파일(암호화할 문서) 를 Jenkins Credential 을 통해 프로젝트에 이식
  4. Gradle Build -> .jar 생성
  5. Docker Image로 Build -> Docker Hub Push
  6. 운영 서버에 기존에 올라가 있는 컨테이너 정리
  7. Git pull & Docker 컨테이터 실행

 


 

 

🐳 도커

1. 도커 설치

1-1. Jenkins(배포서버) & Dev(운영서버)

# Ubuntu의 패키지 목록을 업데이트
sudo apt update 

# 도커 설치
sudo apt install -y docker.io

# 도커 컴포즈 설치 (도커 이미지 관리를 쉽게 하기 위해서 docker-compose 사용)
sudo apt install docker-compose

# docker에 sudo 권한 주기
sudo usermod -aG docker ubuntu

# 서버 로그아웃
logout

# 재 접속 후 확인
id -nG

2. Dockerfile 작성

Spring jar 파일을 빌드하기위해 필요한 Dockerfile 을 프로젝트 root 디렉토리에 작성한다. 

 

# 프로젝트 Java 버전에 맞는 openjdk 이미지 설정
FROM openjdk:17-jdk

# Gradle로 빌드한 jar 파일의 위치를 변수로 설정
# gradle 빌드를 하면 build/libs 하위에 *.jar 생성됨. 해당 '*.jar'를 'app.jar'로 복사
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

# 고정 실행 지시어 (이미지 빌드 명령)
ENTRYPOINT ["java", "-jar", "app.jar"]

3. Docker-compose 작성

여러가지 컨테이너를 관리하기 편하게 docker-compose 를 사용한다.

3-1. 배포서버 (jenkins)

배포서버에 docker-compose.yaml은 원하는 디렉토리 root 에 작성해준다.
docker-compose 를 활용해 jenkins, nginx, certbot 컨테이너를 실행시킨다.

 

jenkins 만 사용하실 분들은 nginx 부터 사용안하셔도 됩니다.

 

디렉토리 구조

# 배포 서버 디렉토리 구조 (nginx, certbot 미사용)
docker
├── docker-compose.yml
└── jenkins_home/


# 배포 서버 디렉토리 구조 (nginx, certbot 사용)
docker
├── docker-compose.yml
├── jenkins_home/
├── nginx/
│   ├── sites-available/
│   ├── sites-enabled/
│   └── nginx.conf
└── certbot/
    ├── letsencrypt/
    └── www/

 

 

docker-compose.yaml

services:
  jenkins:
    container_name: jenkins
    image: jenkins/jenkins:lts                        # Jenkins 이미지 지정
    restart: unless-stopped                           # 서버 재시작 시 컨테이너도 같이 재시작
    user: root
    ports:
      - "9090:8080"                                   # 호스트 포트 (외부) : 컨테이너 포트 (내부)
    volumes:
      - ./jenkins_home:/var/jenkins_home              # 호스트 경로 (실제) : 컨테이너 경로 (가상)
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - my_network

## 여기부터 필수 아님
## jenkins 서버를 dns 에 연결해주고 싶어 nginx 와 certbot(ssl) 을 세팅해서 필요한 분들만 사용하세요.

  # nginx
  nginx:
    container_name: nginx_docker
    image: nginx
    environment:
      - TZ=Asia/Seoul
    ports:
      - "80:80"
      - "443:443"
    restart: unless-stopped
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/sites-enabled:/etc/nginx/sites-enabled
      - ./nginx/sites-available:/etc/nginx/sites-available
      - ./certbot/letsencrypt:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    depends_on:
      - certbot
      - jenkins
    command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
    networks:
      - my_network

  certbot:
    container_name: certbot_docker
    image: certbot/certbot
    restart: unless-stopped                           # 서버 재시작 시 컨테이너도 같이 재시작
    volumes:
      - ./certbot/letsencrypt:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
    networks:
      - my_network

networks:
  my_network:

 

3-2. 운영서버 (spring)

docker-compose.yaml 작성

  • 스프링 프로젝트 root 디렉토리에 docker-compose.yaml를 작성한다.

 

 

docker-compose.yaml

services:
  [서비스 이름]:
    image: [프로젝트 이미지]
    container_name: project_container
    ports:
      - 8080:8080						# 호스트 포트 (외부) : 컨테이너 포트 (내부)
    networks:
      - [필요시 네트워크 설정]

# 필요시 네트워크 설정 (필수 아님)
# 운영 서버에 nginx, mysql, spring 이 같이 올라가서 컨테이너들을 연결해주는 네트워크 설정
networks:
  [네트워크 이름]:
    external: true

 

4. Docker hub 설정

Docker Image 를 push 하고 운영 서버에서 pull 받기 위해 Docker hub 에 회원가입과 레포지토리를 생성한다.

push 할 때 우측에 보이듯이 명령어로 접근하면 된다.

 

docker push Namespace/Repository_name:tagname

 


 

💁‍♂️ Jenkins 서버 설정

1. 메모리 swap 설정 (ec2만)

EC2 프리티어는 메모리가 부족해서 메모리 swap 설정을 통해 늘려준다.
보통 기존의 2배로 설정. EC2 프리티어 메모리 사이즈: 1G

EC2 프리티어가 아니거나 서버 사양이 괜찮다면 넘어가자.

### jenins 서버에서 실행

# 2GB 크기의 스왑 파일을 생성
sudo fallocate -l 2G /swapfile

# /swapfile의 권한을 600으로 설정
sudo chmod 600 /swapfile

# /swapfile을 스왑 파일로 설정
sudo mkswap /swapfile

# /swapfile을 스왑 파일로 활성화
sudo swapon /swapfile

2. Jenkins 설치

jenkins 컨테이너 내부에서 docker 이미지를 빌드하려면 docker 를 사용해서 빌드를 해야한다.
기본적으로 jenkins 컨테이너를 올리면 내부에는 docker 가 없기 때문에 방법이 필요하다.

기본적으로 두가지 방법이 있는데 DinD 방식과 DooD 방식이 있다.

DinD 방식은 컨테이너 안에 docker 데몬을 추가로 동작시킵니다.
DooD 방식은 컨테이너 내부에 새로운 컨테이너 서비스를 만들지 않고 기존에 컨테이너를 사용합니다.
즉, 호스트의 docker 를 사용할 수 있는데 docker.sock 소켓을 활용해서 도커 명령을 사용할 수 있습니다.

저는 DooD 방식을 채택해서 진행하겠습니다.

2-1. docker-compose.yml 실행

위에서 배포서버에 작성한 docker-compose.yml 파일이 있는 디렉토리로 이동해서 명령어를 실행합니다.

docker-compose up -d

2-2. docker apt repository, docker ce 설치

# 배포서버(젠킨스)에서 jenkins 컨테이너의 shell에 접속
docker exec -it -u root jenkins bash

# 컨테이너 내부에서 docker apt repository 구성 및 docker ce 바이너리 설치
apt-get update && \
apt-get -y install apt-transport-https \
     ca-certificates \
     curl \
     gnupg2 \
     software-properties-common && \
curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg > /tmp/dkey; apt-key add /tmp/dkey && \
add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \
   $(lsb_release -cs) \
   stable" && \
apt-get update && \
apt-get -y install docker-ce

2-3. jenkins에서 host docker 접근 그룹에 추가

### jenkins 컨테이너 내부에서 진행

# "docker"라는 이름의 그룹을 생성
groupadd -f docker

# "jenkins" 사용자를 "docker" 그룹에 추가
usermod -aG docker jenkins

# jenkins 컨테이너 나오기
exit

2-4. host docker 접근 권한 부여

### jenkins 컨테이너 외부(호스트)에서 진행

# /var/run/docker.sock 파일의 소유권을 root 사용자와 docker 그룹으로 변경
chown root:docker /var/run/docker.sock

3. Jenkins 접속 및 설정

3-1. 로그인 & 계정 설정

jenkins 페이지 접속

  • http://서버 ip 주소:[docker-compose 에서 설정한 port]
# 접속 비밀번호 확인 명령어
docker exec -it jenkins bash -c "cat /var/jenkins_home/secrets/initialAdminPassword"

 

 

Install Suggested plugins 를 선택후 plugins 설치가 끝나면 계속 화면을 따라 계정을 생성해준다

 

Jenkins URL 설정이 나오면 위에서 접속했던 [ http://서버 ip 주소:port ] 로 설정

3-2. plugins 설치

Dashboard > Jenkins 관리 > Plugins > Available plugins 들어가서 다음 플러그인을 추가로 설치해준다.

  • Generic Webhook Trigger
  • GitHub Integration
  • GitHub Pull Request Builder
  • publish over ssh
  • gradle

3-3. Credentials 관리

1. Github Personal access token 발급

프로필 > Settings > Developer Settings > Personal access tokens > Tokens (classic) >

우측 상단 Generate new token > Generate new token (classic) 선택

Select scopes 에서 repo 와 admin:repo_hook 체크후 생성

중요!! 한번 보면 다시는 토큰을 못봐서 [토큰 복사] 해두기

 

2. Jenkins 에 Credentials 등록

Dashboard > Jenkins 관리 > Credentials > Stores scoped to Jenkins > (global) > Add credentials

 

1. GitHub Web Hook 설정을 위한 credential

  • kind: Secret text 선택
  • Scpoe: Global 선택
  • Secret: GitHub Personal access tokens 입력
  • ID: 식별 가능한 이름 입력
  • Description: 설명 입력

 

2. GitHub git pull 설정을 위한 credential

  • kind: Username with password 선택
  • Scpoe: Global 선택
  • username: GitHub 계정 아이디
  • password: GitHub Personal access tokens 입력
  • ID: 식별 가능한 이름 입력
  • Description: 설명 입력

 

3. Docker Hub 설정을 위한 credential

  • kind: Username with password 선택
  • Scpoe: Global 선택
  • username: Docker Hub 계정 아이디
  • password: Docker Hub 계정 비밀번호
  • ID: 식별 가능한 이름 입력
  • Description: 설명 입력

 

4. 프로젝트 비밀 문서 등록 credential (필수X)

 

혹시 .env 나 application-secret.yaml 같은 git 에 올라가지않는 비밀파일이 있다면 jenkins 에 등록하고 프로젝트 빌드시 사용하고 싶으면 등록합니다.

  • kind: Secret file 선택
  • Scpoe: Global 선택
  • file : 비밀 파일 등록
  • ID: 식별 가능한 이름 입력
  • Description: 설명 입력

3-4. GitHub Web Hook 연결

1. jenkins github server 등록

Dashboard > Jenkins 관리 > System - Github

  • Name: 식별 가능한 이름
  • API URL: 수정 x
  • Credentials: 위에서 등록한 1번 Credentials

 

 

2. github web-hook 등록

Repository > Settings > Webhooks > Add webhook

  • Payload URL: 서버 url/github-webhook/
  • Content type: application/json
  • Just the push event ✅
  • Active ✅

3-5. 운영 서버 등록

Dashboard > Jenkins 관리 > System - SSH Servers

  • Name: 식별 가능한 이름
  • Hostname: 운영 서버 ip
  • Username: 서버 유저 이름, EC2 우분투 기본의 경우 ubuntu
  • Remote Directory : 파일이 업로드될 디렉토리. 기본은 루트 디렉토리

고급 > Use password authentication, or use a different key 체크 ✅

  • EC2 pem 혹은 ssh key 입력

 

[Use password authentication, or user a different key]

 

 


 

🚀 Jenkins Item(job)

1. Item 생성

Dashboard > 새로운 Item > 이름 입력, Pipeline 선택 후 > OK

 

2. Githup 프로젝트 등록

생성한 Item > Configure > General > Github project 선택 > Project url 에 git 주소 입력

 

3. 빌드 트리거 등록

생성한 Item > Configure > General > Build Triggers > GitHub hook trigger for GITScm polling 선택

 

4. 파이프라인 설정

젠킨스 Item 이 작업할 파이프라인을 등록합니다.

생성한 Item > Configure > Pipeline > Pipeline script from SCM > SCM - Git

Git 선택 후,
Repositories 에서 git 주소, Credentials 에서 아까 등록한 git pull 용 선택
Branches to build 에서는 Jenkinsfile 을 찾을 브랜치를 입력합니다.

Script Path 에 Jenkinsfile 을 입력합니다.

git에 Jenkinsfile 이 안올라와있다면 Pipeline script from SCM 말고 Pipeline script 를 선택해서 script 를 이어서 작성하면 됩니다.

Pipeline script from SCM 를 사용하려면 프로젝트 root 디렉토리에 Jenkinsfile 파일을 만들고 다음 스크립트 코드를 입력하면 됩니다.

 

5. 파이프라인 스크립트 코드

pipeline {
    agent any

	// 환경 변수 설정
    environment {
    	// 도커 이미지명
        DOCKER_IMAGE = 'Namespace/Repository_name'
        // 도커 접속을 위한 등록한 credential id
        DOCKERHUB_CREDENTIALS = 'docker_hub_id'
        // 도커 이미지 태그
        SYS_VERSION = '1.1.6'
        // 배포할 원격서버 이름 (SSH SERVER name)
        SSH_SERVER = 'dev_server'
    }

    stages {
        // git pull 받는 단계
        // 원하는 branch, 등록한 git credential, git 주소 입력
        stage('git_checkout') {
            steps {
                git branch: 'develop', credentialsId: 'github_pull', url: 'https://github.com/***'
            }
        }


        // 비밀파일 복사 (필수X)
        // credential 에 비밀파일을 등록했다면 pull 받은 프로젝트에 복사합니다.
        stage('copy secret-file') {
            steps {
                withCredentials([file(credentialsId: 'secret_file', variable: 'secretFile')]) {
                    script {
                        // 복사 경로는 파일이 필요한 디렉토리를 넣어주면 됩니다.
                        sh "cp $secretFile ./src/main/resources/application-secret.yaml"
                    }
                }
            }
        }


        // jar build 단계
        stage('spring build') {
            steps {
                sh "chmod +x ./gradlew"
                sh "./gradlew clean build"
            }
        }


        // 도커 이미지 빌드 단계
        stage('Build Docker Image') {
            steps {
                script {
                    docker.build("${env.DOCKER_IMAGE}:${env.SYS_VERSION}")
                }
            }
        }


        // 새 이미지 도커 허브에 push 단계
        // 위에서 입력한 DOCKERHUB_CREDENTIALS 을 사용해서 도커 로그인 후 허브에 push 합니다.
        stage('Push Docker Image to Docker Hub') {
            steps {
                script {
                    withCredentials([usernamePassword(credentialsId: env.DOCKERHUB_CREDENTIALS, passwordVariable: 'DOCKERHUB_PASSWORD', usernameVariable: 'DOCKERHUB_USERNAME')]) {
                        sh "echo \${DOCKERHUB_PASSWORD} | docker login -u \${DOCKERHUB_USERNAME} --password-stdin"
                    }

                    docker.withRegistry('', env.DOCKERHUB_CREDENTIALS) {
                        docker.image("${env.DOCKER_IMAGE}:${env.SYS_VERSION}").push()
                    }
                }
            }
        }


        // 생성한 이미지 삭제 단계
        stage('Remove Docker Image') {
            steps {
                script {
                    sh "docker rmi ${env.DOCKER_IMAGE}:${env.SYS_VERSION}"
                }
            }
        }


        // 원격 서버에 올라가 있는 도커 이미지 제거 단계
        // 원격 서버에 접속해서 docker_container_name 컨테이너 검색후, 존재하면 제거
        stage('Docker Clean Up') {
            steps {
                script {
                    sshPublisher(
                        publishers: [
                            sshPublisherDesc(
                                configName: env.SSH_SERVER,
                                transfers: [
                                    sshTransfer(
                                        execCommand: '''
                                        if docker ps -a | grep "docker_container_name"; then
                                            docker stop docker_container_name
                                            docker rm docker_container_name
                                            docker rmi docker_container_name
                                        fi
                                        '''
                                    )
                                ],
                                usePromotionTimestamp: false,
                                verbose: true
                            )
                        ]
                    )
                }
            }
        }


        // 원격 서버에 배포 단계
        // 먼저 이 과정에 앞서 원격서버에 git 프로젝트가 있어야합니다.
        // 도커 허브 로그인에 DOCKERHUB_CREDENTIALS 사용
        // git root 디렉토리에 있는 docker-compose.yml 을 실행
        stage('git pull & docker run') {
            steps {
                script {
                    withCredentials([usernamePassword(credentialsId: env.DOCKERHUB_CREDENTIALS, passwordVariable: 'DOCKERHUB_PASSWORD', usernameVariable: 'DOCKERHUB_USERNAME')]) {
                        sshPublisher(
                            publishers: [
                                sshPublisherDesc(
                                    configName: env.SSH_SERVER,
                                    transfers: [
                                        sshTransfer(
                                            execCommand: """
                                            cd /home/user/[git 디렉토리]/ &&
                                            git checkout [원하는 브랜치 이름] &&
                                            git pull origin [원하는 브랜치 이름] &&
                                            clear &&
                                            echo \${DOCKERHUB_PASSWORD} | docker login -u \${DOCKERHUB_USERNAME} --password-stdin
                                            docker-compose -f docker-compose.yml up -d
                                            """
                                        )
                                    ],
                                    usePromotionTimestamp: false,
                                    verbose: true
                                )
                            ]
                        )
                    }
                }
            }
        }

    }

	// 파이프라인의 성공, 실패 여부 상관없이 실행
    post {
        always {
        	// 빌드가 실행된 후 생성된 파일, 디렉터리 등을 모두 삭제하여 워크스페이스를 정리.
            cleanWs()
        }
    }
}

참고