젠킨스로 도커 배포자동화 만들기

#CI CD

젠킨스를 통해서 스프링 도커이미지를 배포자동화를 만들어보았다.

매번 도커를 빌드하고 도커hub에 push후 ssh로 ec2 서버에 접속하여 도커를 pull, run 하는 과정이 번거로웠다.

젠킨스가 깃허브에 코드가 커밋되면 clone하여 gradle build를 진행 후, 도커 이미지를 빌드하여 hub에 push하고 직접 ssh에 접속하여 도커 명령어들을 실행해주면 좋겠다고 생각했다.

젠킨스는 이러한 쉘 스크립팅 작업에 효율적이기에 작업을 진행해보았다.

jenkins와 github를 webhook을 통해서 연동

먼저, jenkins와 내가 커밋할 레포지토리를 trigger할수있도록 webhook 설정을 연동하여야 한다. github 레포지토리에 Jenkinsfile을 작성해두면 trigger하여 Jenkinsfile을 jenkins가 직접 실행한다(SCM) webhook 연동에서 주의해야할 사항은 https://JENKINS_SERVER_IP/github-hooks/로 연동되도록 하여야하는데 주소 마지막 / 는 반드시 기입하여야한다. 이상하게 젠킨스가 인식하지 못하고 302를 응답한다.

지금부터 각 stage들을 만들어가면서 아래와 같은 동작들을 만들어 갈것이다.

  • 레포지토리 클론
  • gradle build
  • docker image build(이미지 생성)
  • docker image dockerhub로 push
  • docker 운영서버에 이미지 재배포

먼저, 레포지토리 클론이다.

pipeline {
  agent any

  stages {
    stage('Clone Repository') {
      steps {
        git branch: 'main', url: 'REPOSITORY_URL'
      }
    }
  }
}

Clone 을 진행하는것은 따로 credentials가 발생하지 않아서 관리할 필요가 없었다. 만약 main외에 브랜치에 접근을 한다면 github credentials를 적용해야할 필요가있다.

다음으로 gradle build이다.

pipeline {
  agent any

  stages {
    stage('Clone Repository') {
      steps {
        git branch: 'main', url: 'REPOSITORY_URL'
      }
    }

    stage('Build') {
      steps {
        echo 'Building from GitHub!'
        sh './gradlew clean build'
      }
    }
  }
}

stage를 추가하며 동작들을 추가하고있다. Build stage를 보면 sh로 문자열로 작성된 명령어들을 실행하는 것을 볼 수 있다. gradle build 동작을 진행할려면 jenkins 이미지 컨테이너 내부에 jdk 가 있어야한다. 해당 프로젝트는 java 17을 사용중이기에 build 하여 docker에 jenkins와 java17이 함께 들어간 이미지를 새로 생성할 것이다.

FROM jenkins/jenkins:lts

USER root

RUN apt-get update && \
    apt-get install -y openjdk-17-jdk docker.io && \
    usermod -aG docker jenkins

USER jenkins

Dockerfile을 작성하고 java17과 docker.io 가 포함된 jenkins 이미지를 새로 생성한다. docker.io 는 docker cli(build, push) 할때 필요하기에 지금 한번에 같이 설치한다.

docker build -t jenkins-with-java17-docker:latest .

다음으로 docker image build를 통해서 이미지를 생성한다.

pipeline {
  agent any

  environment {
    IMAGE_NAME = "amazon7737/hello-world-image"
    IMAGE_TAG = "build-${env.BUILD_NUMBER}"
  }

  stages {
    stage('Clone Repository') {
      steps {
        git branch: 'main', url: 'REPOSITORY_URL'
      }
    }

    stage('Build') {
      steps {
        echo 'Building from GitHub!'
        sh './gradlew clean build'
      }
    }

    stage('Build Docker Image') {
      steps {
        sh "docker build -t ${IMAGE_NAME}:${IMAGE_TAG} ."
      }
    }
  }
}

stage Build Docker Image를 추가 작성했다. IMAGE_NAME , IMAGE_TAG 는 environment (env라고도 불림)에서 직접 상수로 가져와서 관리할 수 있다.

env.BUILD_NUMBER 는 Jenkins에서 자동으로 제공하는 내장 환경 변수이다. 매 빌드 마다 Jenkins가 1씩 증가시키면서 값을 설정해준다. 이번 작업을 진행하기전에 빌드 버전을 어떻게 관리해야하나 고민이었는데 마침 좋은 방식을 찾았다.

/var/jenkins_home/workspace/spring-hello-world-webhook@tmp/durable-8fcf652d/script.sh.copy: 1: docker: not found

앞에서 java17, docker.io를 같이 jenkins 이미지 파일에 설치하였다. docker.io를 설치한 이유는 이미지 빌드를 위해서는 docker command가 jenkins 이미지 컨테이너 내부에서 사용할 수 있어야하는데, 내 컴퓨터 내의 도커를 사용할 수 있도록 jenkins에게 권한을 주는 방법이 있었고, jenkins 이미지 컨테이너 내부에 docker.io를 설치하는 방법이 있었다. 권한을 전달하는 사항이 파일 권한 자체를 chmod로 변경해야하는게 꺼려져서 컨테이너 용량이 늘어나더라도 jenkins 컨테이너 내부에 docker.io를 설치하는 방법을 택했다.

새로 빌드한 jenkins-with-docker 이미지로 젠킨스를 다시 시작시켜줘야한다.

docker run -d -p 8080:8080 -p 50000:50000 -v /var/run/docker.sock:/var/run/docker.sock -v /Users/mini14/File/jenkins_home_backup:/var/jenkins_home jenkins-with-docker:latest

기존에 jenkins가 가지고있던 데이터가 jenkins_home_backup 폴더에 저장되고 있었다. 해당 폴더와 마운트해주면서, docker 데몬이 정상적으로 실행되기 위해서 docker.sock을 마운트해준다. jenkins-with-docker:latest 이미지를 실행해준다.

docker.sock를 컨테이너가 사용하기 위해서는 권한이 필요하다

sudo chmod 666 /var/run/docker.sock

테스트 환경에서는 권한부여를 하고있지만, 프로덕션 환경이라면 권한 상승은 주의가 필요하다. 해당 레포지토리 프로젝트 내부에 젠킨스가 사용할 docker-compose.yml도 작성해준다.

services:
  jenkins:
    image: jenkins-with-docker:latest
    user: root
    volumes:
      - /Users/mini14/File/jenkins_home_backup:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - "8080:8080"
      - "50000:50000"
volumes:
  jenkins_home_backup:

volumes 들은 나의 컴퓨터 기준으로 설정하였다. 각자 컴퓨터 기준에 맞게 파일 경로를 설정하여야한다. jenkins_home_backup은 젠킨스를 실행하여 작업한 config 파일들이 모여있다. jenkins 컨테이너 내부에 jenkins 계정의 권한을 상승시켜준다.

docker exec -it --user root <container id> bash
getent group docker // GID 확인
groupadd -g 999 docker
usermod -aG docker jenkins

GID를 도커내부에서 일치시켜준다. docker.sock 를 jenkins가 사용할 수 있도록 하기위한 작업이다. 권한에도 문제가 없이 jenkins 계정이 잘 동작한다면, 문제없이 docker build가 진행될 것이다.

다음으로 docker image push 과정을 진행해보겠다.

pipeline {
  agent any

  environment {
    IMAGE_NAME = "amazon7737/hello-world-image"
    IMAGE_TAG = "build-${env.BUILD_NUMBER}"
  }

  stages {
    stage('Clone Repository') {
      steps {
        git branch: 'main', url: 'REPOSITORY_URL'
      }
    }

    stage('Build') {
      steps {
        echo 'Building from GitHub!'
        sh './gradlew clean build'
      }
    }

    stage('Build Docker Image') {
      steps {
        sh "docker build -t ${IMAGE_NAME}:${IMAGE_TAG} ."
      }
    }

    stage('Push Docker Image') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'ID', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
          sh """
              echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin docker push ${IMAGE_NAME}:${IMAGE_TAG}
            """
        }
      }
    }
  }
}

push 작업에서 많은 문제가 있었다. push를 진행할려면 docker hub credential이 필요하였고, 문자열로 작성된 명령어들을 sh 로 실행하는 방식을 선택하였다.

credentialId는 jenkins 설정 -> credentials -> global 을 눌러 add credential를 통해서 등록하였다. Kind는 Username with password로 설정하고 Username에 Docker id, Password에 Docker에서 발행한 Access Token을 작성하였다. ID는 credentialsId에서 말하는 ID이다. 일치시킬 이름을 작성해준다.

문제가 없다면 docker hub에 정상적으로 나의 계정에 push가 성공된다.

다음으로, ssh로 EC2 인스턴스에 jenkins가 직접 접속하여 명령어를 실행한다.

pipeline {
  agent any

  environment {
    IMAGE_NAME = "amazon7737/hello-world-image"
    IMAGE_TAG = "build-${env.BUILD_NUMBER}"
    REMOTE_HOST = "EC2 PUBLIC IP"
    REMOTE_DIR = "/home/ubuntu" # 작업할 영역
  }

  stages {
    stage('Clone Repository') {
      steps {
        git branch: 'main', url: 'REPOSITORY_URL'
      }
    }

    stage('Build') {
      steps {
        echo 'Building from GitHub!'
        sh './gradlew clean build'
      }
    }

    stage('Build Docker Image') {
      steps {
        sh "docker build -t ${IMAGE_NAME}:${IMAGE_TAG} ."
      }
    }

    stage('Push Docker Image') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'ID', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
          sh """
              echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin docker push ${IMAGE_NAME}:${IMAGE_TAG}
            """
        }
      }
    }
    stage('Deploy to Remote Server') {
      steps {
        withCredentials([file(credentialsId: 'remote-server-key', variable: 'PRIVATE_KEY')]) {
          sh """
            ssh -o StrictHostKeyChecking=no -i ${PRIVATE_KEY} ubuntu@${REMOTE_HOST}
            cd ${REMOTE_DIR} &&
            docker pull ${IMAGE_NAME}:${IMAGE_TAG} &&
            docker-compose down &&
            IMAGE_TAG=${IMAGE_TAG} docker-compose up -d
            '
            """
        }
      }
    }
  }
}

접속할 EC2 인스턴스의 IP 값은 REMOTE_HOST로 상수로 관리한다. REMOTE_DIR 로 해당 내용을 작업할 영역으로 진입한다. 젠킨스가 직접들고온 IMAGE_NAME, IMAGE_TAG 를 가지고 pull을 진행한다. 따로 이미지 네임과 태그를 쉘스크립트로 관리하고 있지 않아도 되서 상수로 가져오는 방식이 편리한것 같다. docker-compose 명령어로 기존 서버를 중지시키고, IMAGE_TAG 상수에 태그값을 주입하여 docker-compose up -d 로 이미지 컨테이너를 시작시킨다. 이때 사용하는 docker-compose.yml 은 아래와 같다.

version: "3"
services:
  app:
    image: amazon7737/hello-world-image:${IMAGE_TAG}
    ports:
      - "8080:8080"
    restart: always

추후에 프로덕션 환경에서는 graceful shutdown으로 요청받고 있는 작업과 무중단 배포를 고려했을때 변경해야할 부분이 존재한다. 현재는 테스트로 진행하고 있는 것이기에 해당 설정 파일을 사용한다.

PRIVATE_KEY는 EC2 에 private key를 직접 credential로 등록해두었다. remote-server-key 으로 등록하였다. 사용하던 private key 를 public key로 변환하여 연동해야하나 고려했지만, 현재 사용하는 EC2는 public 서브넷을 열어두고 있지 않아서 private key로만 접속하는 -i 설정을 사용하여 ssh 접속을 시도할 것이다. 따라서 secret file 을 업로드하여 credential을 등록하였다. 내가 작업시 사용하는 계정은 현재 ubuntu 이다. 다른 계정을 사용한다면 접속 계정명을 수정하여야 한다.

아래 시스템 로그는 무사히 재배포까지 진행되었을때의 로그이다.

Started by GitHub push by [USER]
Obtained Jenkinsfile from git https://github.com/[USER]/[REPO]
[Pipeline] Start of Pipeline
[Pipeline] node
Running on Jenkins in /var/jenkins_home/workspace/[PROJECT]-webhook
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Declarative: Checkout SCM)
[Pipeline] checkout
Selected Git installation does not exist. Using Default
The recommended git tool is: NONE
using credential [CREDENTIAL]
 > git rev-parse --resolve-git-dir /var/jenkins_home/workspace/[PROJECT]-webhook/.git # timeout=10
Fetching changes from the remote Git repository
 > git config remote.origin.url https://github.com/[USER]/[REPO] # timeout=10
Fetching upstream changes from https://github.com/[USER]/[REPO]
 > git --version # timeout=10
 > git --version # 'git version 2.39.5'
using GIT_ASKPASS to set credentials docker-hub-credentials
 > git fetch --tags --force --progress -- https://github.com/[USER]/[REPO] +refs/heads/*:refs/remotes/origin/* # timeout=10
 > git rev-parse refs/remotes/origin/main^{commit} # timeout=10
Checking out Revision [COMMIT_HASH] (refs/remotes/origin/main)
 > git config core.sparsecheckout # timeout=10
 > git checkout -f [COMMIT_HASH] # timeout=10
Commit message: "Update Controller.java"
 > git rev-list --no-walk [COMMIT_HASH] # timeout=10
[Pipeline] }
[Pipeline] // stage
[Pipeline] withEnv
[Pipeline] {
[Pipeline] withEnv
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Clone Repository)
[Pipeline] git
Selected Git installation does not exist. Using Default
The recommended git tool is: NONE
No credentials specified
 > git rev-parse --resolve-git-dir /var/jenkins_home/workspace/[PROJECT]-webhook/.git # timeout=10
Fetching changes from the remote Git repository
 > git config remote.origin.url https://github.com/[USER]/[REPO].git # timeout=10
Fetching upstream changes from https://github.com/[USER]/[REPO].git
 > git --version # timeout=10
 > git --version # 'git version 2.39.5'
 > git fetch --tags --force --progress -- https://github.com/[USER]/[REPO].git +refs/heads/*:refs/remotes/origin/* # timeout=10
 > git rev-parse refs/remotes/origin/main^{commit} # timeout=10
Checking out Revision [COMMIT_HASH] (refs/remotes/origin/main)
 > git config core.sparsecheckout # timeout=10
 > git checkout -f [COMMIT_HASH] # timeout=10
 > git branch -a -v --no-abbrev # timeout=10
 > git branch -D main # timeout=10
 > git checkout -b main [COMMIT_HASH] # timeout=10
Commit message: "Update Controller.java"
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Build)
[Pipeline] echo
Building from GitHub!
[Pipeline] sh
+ ./gradlew clean build
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :clean
> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
> Task :test
> Task :check
> Task :build

BUILD SUCCESSFUL in 7s
8 actionable tasks: 8 executed
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Build Docker Image)
[Pipeline] sh
+ docker build --platform linux/amd64 -t [DOCKER_USER]/[IMAGE_NAME]:build-25 .
#1 [internal] load build definition from Dockerfile
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 157B done
#1 DONE 0.0s

#2 [internal] load metadata for docker.io/library/openjdk:17-jdk-slim
#2 DONE 1.4s

#3 [internal] load .dockerignore
#3 transferring context: 2B done
#3 DONE 0.0s

#4 [1/3] FROM docker.io/library/openjdk:17-jdk-slim
#4 DONE 0.0s

#5 [internal] load build context
#5 transferring context: 20.16MB 0.1s done
#5 DONE 0.1s

#6 [2/3] WORKDIR /app
#6 CACHED

#7 [3/3] COPY build/libs/*.jar app.jar
#7 DONE 0.0s

#8 exporting to image
#8 exporting layers 0.4s done
#8 exporting manifest done
#8 exporting config done
#8 naming to docker.io/[DOCKER_USER]/[IMAGE_NAME]:build-25 done
#9 DONE 0.5s
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Push Docker Image)
[Pipeline] withCredentials
Masking supported pattern matches of $DOCKER_PASS
[Pipeline] {
[Pipeline] sh
Warning: A secret was passed to "sh" using Groovy String interpolation, which is insecure.
		 Affected argument(s) used the following variable(s): [DOCKER_PASS]
		 See https://jenkins.io/redirect/groovy-string-interpolation for details.
+ + echodocker login **** -u [EMAIL]
 --password-stdin
WARNING! Your password will be stored unencrypted in /var/jenkins_home/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded
+ docker push [DOCKER_USER]/[IMAGE_NAME]:build-25
The push refers to repository [docker.io/[DOCKER_USER]/[IMAGE_NAME]]
[LAYER_HASH]: Waiting
[LAYER_HASH]: Waiting  
[LAYER_HASH]: Waiting
[LAYER_HASH]: Waiting
[LAYER_HASH]: Waiting
... (생략) ...
[LAYER_HASH]: Layer already exists
[LAYER_HASH]: Layer already exists
[LAYER_HASH]: Pushed
build-25: digest: sha256:[DIGEST_HASH] size: 1294
[Pipeline] }
[Pipeline] // withCredentials
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Deploy to Remote Server)
[Pipeline] withCredentials
Masking supported pattern matches of $PRIVATE_KEY
[Pipeline] {
[Pipeline] sh
Warning: A secret was passed to "sh" using Groovy String interpolation, which is insecure.
		 Affected argument(s) used the following variable(s): [PRIVATE_KEY]
		 See https://jenkins.io/redirect/groovy-string-interpolation for details.
+ ssh -o StrictHostKeyChecking=no -i **** ubuntu@[SERVER_IP]
                        cd /home/ubuntu &&
                        docker pull [DOCKER_USER]/[IMAGE_NAME]:build-25 &&
                        docker-compose down &&
                        IMAGE_TAG=build-25 docker-compose up -d

build-25: Pulling from [DOCKER_USER]/[IMAGE_NAME]
[LAYER_HASH]: Already exists
[LAYER_HASH]: Already exists
[LAYER_HASH]: Already exists
[LAYER_HASH]: Already exists
[LAYER_HASH]: Pulling fs layer
[LAYER_HASH]: Verifying Checksum
[LAYER_HASH]: Download complete
[LAYER_HASH]: Pull complete
Digest: sha256:[DIGEST_HASH]
Status: Downloaded newer image for [DOCKER_USER]/[IMAGE_NAME]:build-25
docker.io/[DOCKER_USER]/[IMAGE_NAME]:build-25
The IMAGE_TAG variable is not set. Defaulting to a blank string.
Stopping ubuntu_app_1 ...
Stopping ubuntu_app_1 ... done
Removing ubuntu_app_1 ...
Removing ubuntu_app_1 ... done
Removing network ubuntu_default
Creating network "ubuntu_default" with the default driver
Creating ubuntu_app_1 ...
Creating ubuntu_app_1 ... done
[Pipeline] }
[Pipeline] // withCredentials
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Declarative: Post Actions)
[Pipeline] echo
✅ Successfully deployed: [DOCKER_USER]/[IMAGE_NAME]:build-25
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // withEnv
[Pipeline] }
[Pipeline] // withEnv
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS

젠킨스를 활용하여 도커 재배포 자동화를 만들어보았다. 코드를 커밋하기만 하면 무사히 잘 진행이 되는것을 볼 수 있다. 이번 작업을 해보고 추가로 생각난 고민점은 분산 서버일때는 어떻게 자동화를 할것인지 그리고 각 설정 파일에 들어가는 private한 정보값들은 취약하지 않은 상태로 관리할 수 있을지이다.