Build and Deploy Dockerfile in Jenkins

[Start RESTful Service With Spring Boot 2.x + Kotlin] 글에서 유일하게 다루지 못했던 주제가 있었다. 바로 Jenkins를 이용한 CI/CD 연동이다.

글을 올린지 약 1달 반 정도 지났지만, 성공적으로 빌드할 수 있게 되었고 이를 작성해보려 한다.

환경설정

Jenkins 내부에서 Docker를 빌드하려면 Docker 바이너리가 Jenkins에 포함되어 있어야 한다. Jenkins 역시 도커 이미지로 배포되기 때문에, 먼저 커스텀 Jenkins 이미지를 수정할 필요성이 있다.

Docker + Jenkins 연동은 [Run Docker + Jenkins for Android Build] 글에서 다루니 그쪽부터 보면 좋을 것 같다.

Docker를 설치하기 위해서 추가적으로 필요한 의존성은 다음과 같다.

  • ca-certificates
  • curl
  • gnupg2
  • file
  • lxc
  • apt-transport-https

이 6개를 apt install로 통해 설치하고 순서대로 명령어를 입력하면 된다.

## Install requirements
RUN dpkg --add-architecture i386
RUN rm -rf /var/lib/apt/list/* && apt-get update && apt-get install ca-certificates curl gnupg2 software-properties-common git unzip file apt-utils lxc apt-transport-https -y

그 다음, docker 서버로부터 인증서와 레포지토리 주소를 받아서 설치하면 된다.

## Install Docker-ce into Image
RUN curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg > /tmp/dkey;
RUN apt-key add /tmp/dkey
RUN add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") $(lsb_release -cs) stable"
RUN apt-get update && apt-get install docker-ce -y --no-install-recommends
RUN usermod -a -G docker jenkins

여기까지 마치면 Jenkins 이미지에 Docker-CE 바이너리가 설치된다.

아니면, 업데이트 된 [WindSekirun/Jenkins-Android-Docker] 이미지를 활용해도 문제는 없다.

JenkinsFile 작성

Spring-Boot 프로젝트를 빌드하고, 이를 Docker로 배포하는 과정에 있어서 Jenkins에 필요한 작업을 정의하는 작업이 필요하다.

Jenkins는 각 파이프라인 상에서 수행해야 될 작업을 Jenkinsfile 이라는 파일에 정의하고, 이를 빌드할 때 마다 사용할 수 있다.

필요한 작업을 정리하면 다음과 같을 것이다.

  • 환경설정
  • Gradle 빌드
  • DockerImage 빌드
  • DockerHub로 이미지 푸시
  • 빌드된 이미지 제거

이 중, gradle 까지의 작업을 반영하면 다음과 같다

pipeline {
    agent any
    stages {
        stage('Environment') {
            parallel {
                stage('chmod') {
                    steps {
                        sh 'chmod 755 ./gradlew'
                    }
                }
                stage('display') {
                    steps {
                        sh 'ls -la'
                    }
                }
            }
        }
        stage('Build Jar') {
            steps {
                sh './gradlew build'
            }
        }
     }
}

Docker 이미지 빌드

환경변수에 registry 이라는 변수를 정의한다.

environment {
        registry = "windsekirun/uploadfileboot"
}

registry 는 이미지에 대한 태그이며, https://hub.docker.com/r/windsekirun/uploadfileboot 이런 식으로 접근이 가능해진다.

그 다음 docker 빌드 명령어를 작성한다. 기본적인 빌드 명령어는 docker build -t windsekirun/uploadfileboot:latest .이므로 Jenkinsfile의 형식에 맞게 작성하면 된다.

stage('Build docker image') {
            steps {
                sh 'docker build -t $registry:latest .'
            }
}

latest 대신 $BUILD_NUMBER 나 다른 환경변수를 이용해서 사용도 가능하다.

빌드된 이미지 푸시

Jenkins 설정 > 인증정보에 정보를 추가한다

그리고 환경변수에 추가한 정보의 id를 정의하면, 준비는 완료된다.

environment {
        registry = "windsekirun/uploadfileboot"
        registryCredential = 'dockerhub'
}

마지막으로, registry에 푸시하는 명령어는 docker push windsekirun/uploadfileboot:latest 이므로, Jenkinsfile 형식에 맞춰 작성한다. 다만 crediental 정보가 필요하므로 withDockerRegistry 명령어를 통해 crediental 정보를 입력한다.

stage('Deploy docker image') {
            steps {
                withDockerRegistry([ credentialsId: registryCredential, url: "" ]) {
                    sh 'docker push $registry:latest'
                }
            }
}

빌드한 이미지 삭제

빌드된 이미지를 계속 남겨둘 필요는 없으므로, 지금까지 빌드한 이미지를 삭제한다.

stage('Clean docker image') {
            steps{
                sh "docker rmi $registry"
            }
}

전체 코드

pipeline {
    environment {
        registry = "windsekirun/uploadfileboot"
        registryCredential = 'dockerhub'
    }
    agent any
    stages {
        stage('Environment') {
            parallel {
                stage('chmod') {
                    steps {
                        sh 'chmod 755 ./gradlew'
                    }
                }
                stage('display') {
                    steps {
                        sh 'ls -la'
                    }
                }
            }
        }
        stage('Build Jar') {
            steps {
                sh './gradlew build'
            }
        }
        stage('Build docker image') {
            steps {
                sh 'docker build -t $registry:latest .'
            }
        }
        stage('Deploy docker image') {
            steps {
                withDockerRegistry([ credentialsId: registryCredential, url: "" ]) {
                    sh 'docker push $registry:latest'
                }
            }
        }
        stage('Clean docker image') {
            steps{
                sh "docker rmi $registry"
            }
        }
     }
}

이 Jenkinsfile 를 가지고 Jenkins를 설정하면 다음과 같이 나오게 된다.

마지막으로 push된 이미지는 Dockerhub 홈페이지에서 확인할 수 있다.

마무리

Jenkins 를 이용해 도커 이미지를 빌드하고 배포하는 과정까지 살펴보았다.

물론 master에 빌드한 것을 그대로 push하는 것은 매우 위험하기 때문에 개발하는 사이클마다 별도로 적용해야 되지만, 개론적인 것은 살펴본 것과 크게 다르지는 않을 것이다.

위에 살펴본 DockerHub에 올리는 것 말고도 private registry에 올리는 것 또한 가능하기에 선택하기 나름일 것 같다.

Kotlin Coverage Test – Static Analysis with Jenkins

Kotlin으로 모듈을 작성하다 보면, 모듈의 각 코드에 대한 단위 테스트와 코드 분석을 진행할 필요가 있다.

특히 그 모듈은 구(공 모양의 도형) 상에 존재하는 x개의 데이터 셋에 대해 y 를 제공했을 때, 데이터 셋 상에서 y에 최근접해있는 데이터를 찾는 모듈이었기 때문이었다.

그래서 모듈을 구성하는 모든 코드에 대한 단위 테스트를 진행하여 최대한 모듈 상의 버그를 없애고 (물론, 로직을 구성하는 최하위 알고리즘 상에 문제는 생길 수는 있다. 다만, 서울에서 멀리 떨어진 지역의 데이터이기 때문에 검증하기도 많이 어렵다.) 퍼포먼스, 보안 상 문제가 될 수 있는 모든 가능성을 없앨 필요가 있었다.

이 글에서는 그 과정에서 도입한 Spek Framework, Jacoco Report, Detekt 에 대해 알아보고, 이 세 개를 Jenkins에 연동하여 Jenkins dashboard 에서 해당 리포트에 대해 보여주는 과정을 살펴보려 한다.

Spek Framework

Spek Framework란 코틀린용 유닛 테스트 프레임워크로 Kotlin의 기여자들이 관리하는 프레임워크이다. (JetBrains 공식 프로젝트는 아니나, JetBrains 가 개발한 흔적은 있다.) 현재는 2.0.0-RC1 버전으로, 특징적인 점이라면 DSL로 유닛 테스트를 진행할 수 있다는 점이다.

class SomeTest: Spek({
    describe("Some test") {
        it("Check value is something") {
            // TEST BODY
        }
    }
})

이러한 구조를 가지고 있어 JUnit와 다른 테스트 프레임워크와는 다르게 쉬운 구조를 가지고 있다.

(이렇게 보면, Ktor도 그렇고 JetBrains 가 개발한 프레임워크들은 DSL를 매우 강조하는 것 같다. 과연 그것이 좋은지는 아직도 잘 모르겠긴 하다.)

임포트는 다음과 같이 진행한다.

buildscript {
    ext.kotlin_version = '1.3.11'
    ext.spek_version = '2.0.0-rc.1'
    ext.junit_version = '4.12'
​
    repositories {
        jcenter()
    }
​
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}
​
dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
    testImplementation "org.spekframework.spek2:spek-dsl-jvm:$spek_version"
    testRuntimeOnly "org.spekframework.spek2:spek-runner-junit5:$spek_version"
    testRuntimeOnly "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
    testCompile("org.assertj:assertj-core:3.11.1")
    testCompile "junit:junit:$junit_version"
}
​
test {
    useJUnitPlatform {
        includeEngines 'spek2'
    }
}

이렇게 작성한 테스트 코드는 gradlew test 로 확인이 가능하다.

Jacoco Report

Jacoco Report는 Java Code Coverage를 구현하는 데 사용하는 툴킷으로, Line, Branch에 대한 Coverage를 제공한다.

임포트는 다음과 같이 한다.

apply plugin: 'jacoco'
​
test {
    useJUnitPlatform {
        includeEngines 'spek2'
    }
    jacoco {
        destinationFile = file("${buildDir}/jacoco/test.exec")
    }
}
​
test.finalizedBy(jacocoTestReport)
​
jacoco {
    // You may modify the Jacoco version here
    toolVersion = "0.8.2"
}
​
jacocoTestReport {
    // Adjust the output of the test report
    reports {
        xml.enabled true
        csv.enabled false
    }
}

gradlew test 로 테스트를 구동했을 경우, destinationFile로 제공한 경로에 exec 파일이 저장된다. 이 exec 파일은 Jacoco 가 생성한 리포트 파일이 포함되며, gradlew jacocoTestReport 명령어로 리포트 파일을 생성할 수 있다. (여기서는 xml, html 리포트를 생성한다.)

Detekt

Detekt는 코틀린에 대한 Static Analysis를 제공하는 툴킷으로, 코틀린으로 코드를 작성함에 있어 피해야하는 패턴들에 대해 감지, 특정 패턴들의 가중치를 파악해서 일정 이상이면 build 자체가 failed 되게 할 수 있어 코드 본연의 문제를 좀 더 파악할 수 있게 하는 툴킷이다.

특히 Codacy (https://www.codacy.com/) 에서 코틀린 프로젝트를 임포트 했을 때 사용하는 분석 툴킷으로, 많은 사람들에 의해 검증된 툴킷이기도 하다.

임포트 과정은 프로젝트 사이트(https://arturbosch.github.io/detekt/) 에 설명되어 있다.

detekt의 경우 gradlew detekt 로 통해 테스트를 진행할 수 있다. 이 때 report는 build/reports/detekt 에 detekt.html 에 저장되며 이 리포트 파일을 참고로 해서 파악할 수 있다.

Jenkins로 실행

위에서 살펴본 Spek Framework, Jacoco, Detekt를 빌드마다 실행하기 위해서 JenkinsFile에 해당 과정을 구현했다.

stage('Test Analysis') {
      parallel {
        stage('Static Analysis') {
          steps {
            sh './gradlew detekt'
            publishHTML(target: [reportDir:'build/reports/detekt/', reportFiles: 'detekt.html', reportName: 'Detekt report'])
          }
        }
        stage('Unit Test') {
          steps {
            sh './gradlew cleanTest test'
            sh './gradlew jacocoTestReport'
            publishHTML(target: [reportDir:'build/reports/jacoco/test/html', reportFiles: 'index.html', reportName: 'Code Coverage'])
          }
        }
      }
    }

gradlew build 가 끝난 다음, 이 단계를 실행하게 하면 된다. publishHTML은 특정 Html 파일을 Jenkins 메뉴 상에 표시해서 쉽게 볼 수 있게 한다.

Jenkins에 테스트 결과에 대한 Output 출력

Test Code를 작성할 때 println 메서드로 Test 콘솔에 Output를 출력할 수 있으나, 위 JenkinsFile를 가지고 실행하면 Output가 나오지 않는다.

이 때는 https://stackoverflow.com/a/36130467 링크에 있는 답변을 사용하면 된다.

최종 결과

전체 파이프라인 통과
각 테스트에 대해 PASSED / FAILED / SKIPPED 등이 표시된다. 만일 Output가 있을 경우 그 아래에 표시된다.
마지막에 총 결과가 표시된다.
Detekt 분석 결과는 콘솔에도 표시된다. 물론, 리포트에도 볼 수 있다.
Jenkins 의 메뉴에 Code Coverage, Detekt Report가 생긴 것을 확인할 수 있다.
Detekt가 생성한 report 파일이다.
Jacoco가 생성한 Coverage Report로, Line Coverage는 99%, Branch Coverage는 94%로 측정되었다.

마무리

제목은 다소 거창하지만, 실제로 한 행동은 그렇게 어렵지는 않다. 다만 이 글에서 사용한 프로젝트는 순수 Kotlin 프로젝트로 안드로이드 프로젝트는 아니다.

이 면에서 보면, 안드로이드 프로젝트에 대해 유닛 테스트를 진행하는 것은 다소 어렵기 때문에 조금 진입장벽이 높을 수는 있다고는 판단된다.

하지만 작은 것 부터 나아가면, 점점 더 나아질 것이라 생각한다.

새 인스턴스로 확장 이전과 후기

이번 연휴(2018. 12. 22 ~ 2018. 12. 25) 동안 지금까지 운영해오던 UzukiLive 서버를 확장이전 하여 새롭게 환경을 구축하게 되었습니다.

기존 인스턴스와 문제점

기존 환경은 이렇게 설정되어 있습니다.

  • Vultr VC2 1core 2GB (UzukiLive 인스턴스) -> 블로그, nextcloud. 도커로 되있지 않음
  • Vultr VC2 2core 4GB (Artifactory 인스턴스) -> Artifactory, Jenkins. 도커로 되어있음

그리고 메일 서버의 경우 Zoho 플랫폼을 빌려 사용하였고, 처음에 삽질을 너무 크게 한 나머지 uzuki.live 로 접속하면 SSL 에러가 발생하곤 했습니다.
그 외 문제점이라고 하면 FTP를 쉽게 사용할 수 없거나, 너무 설정이 여기저기 있어 확장하려고 해도 쉽지 않았습니다. (오죽하면 인스턴스를 두개 생성해서 관리를 했지만요… (._. )

당연히 비용도 비용이니 좋지 않은 사양에 월 30달러나 지불해야 한다는 것은 이해가 가지도 않긴 합니다.

따라서 이번 연휴때 계획을 잡고 신규 인스턴스에 전부 이전하기로 결정했습니다.

신규 인스턴스

신규 인스턴스는 Vultr의 VC2 4core 8GB, 100GB SSD 입니다. 이전 사양보다 약 2(Artifactory 인스턴스 기준) ~ 4(UzukiLive 인스턴스 기준)배 이상 사양이 증가하였습니다.

사양을 대폭 올린 이유로는 Jenkins 때문인데, 2core 4GB 사양에서 gradle 빌드를 두 개 이상 돌리는 순간 컨테이너가 메모리 부족으로 죽어버리는 대참사가 발생하여, 빌드를 두 개 이상 동시에 돌리더라도 문제가 없도록 구성하였습니다.

신규 인스턴스는 모든 요소가 Docker에 의해 관리되는 Dockerize 환경을 사용할 것이고, 아래 범주로 서비스를 관리할 예정입니다.

  • Reverse Proxy -> nginx
  • Stack: Infra -> adminer, pure-ftpd, docker-telegram-notifier
  • Stack: Blog -> MariaDB + WordPress
  • Stack: Build-Automation -> Jenkins, Artifactory
  • Stack: Mail -> docker-mailserver(IMAP/SMTP) + rainloop (Web UI)
  • Stack: Intro -> uzukilive-intropage
  • Stack: Octobox -> Octobox + Redis + PostgreSQL

그러면, 각 범주마다 설명을 약간 추가하면서 기능을 소개하려 합니다.

Reverse Proxy + SSL

기존 UzukiLive 에서도 사용했던 Nginx를 사용하고, 여기에 LetsEncrypt를 이용하여 모든 외부용 서비스에 SSL를 제공할 계획입니다.

보통 nginx-proxy + letsencrypt-companion 조합으로 사용하지만 이 조합의 경우 docker 소켓에 붙어 일정 시간마다 스캔을 해서 추가하는 방식으로 되어있어, 그거보다는 좀 더 귀찮지만 하나의 설정 파일(conf)로 존재하는 것이 좋다고 생각되었습니다.

따라서 새 서비스를 연동할 때 마다 아래의 명령어를 통해 인증서를 발급받았습니다.

docker run -it --rm -v /data/cert:/etc/letsencrypt -v /data/cert-data:/data/letsencrypt certbot/certbot certonly --webroot --webroot-path=/data/letsencrypt -d uzuki.live

저 방법을 사용하기 위해서는 일정의 예제 conf 파일을 만들어 nginx를 가동시킬 필요가 있는데, 샘플 nginx 파일을 만들고 DONAME_NAME 만 교체해서 생성하도록 했습니다.

server {
    listen      80;
    listen [::]:80;
    server_name uzuki.live;

    location / {
        rewrite ^ https://$host$request_uri? permanent;
    }

    location ^~ /.well-known {
        allow all;
        root  /data/letsencrypt/;
    }
}

Stack: Infra

여기에는 3개의 서비스가 들어가는데, adminer 는 MySQL/MariaDB의 정보를 보거나 수정할 수 있는 프로그램으로 phpmyadmin 대체용입니다

그 다음으로 pure-ftpd는 FTP 서버로 /home/pyxis 경로에 접근하여 데이터를 주고 받을 수 있게 설정했습니다.

마지막으로 docker-telegram-notifier 는 미리 만들어둔 Telegram bot로 컨테이너의 시작/중지 등의 상태를 보내줍니다.

가령 Jenkins가 시작되었다면 아래의 메세지를 보내줍니다.

Started container jenkins
Image: windsekirun/jenkins-android-docker:1.0.2
Container ID: ae8f526e8b42d216146fb68ee49f6506631259780f3c75195718786da6245cb6
오늘도 약 400개의 메세지를 받아내는 우즈키(..)

Stack: Blog

여기에는 2개의 서비스가 들어가는데, 워드프레스와 그 DB로 MariaDB를 선택했습니다. 기존 인스턴스에는 MySQL로 되어있고, 이 데이터를 그대로 가져갈 필요가 있었습니다.
따라서 PostgreSQL 보다는 MariaDB를 선택했습니다.

주소는 기존의 blog.uzuki.live 에서 pyxispub.uzuki.live 로 변경했는데, 기존 주소로 들어가면 새로운 주소로 리다이렉션 되도록 기존 인스턴스의 nginx 단을 수정했습니다.

기존 인스턴스는 2주 뒤인 1월 11일쯤에 제거될 예정입니다.

Stack: Build-Automation

여기에는 2개의 서비스가 들어가는데, 각각 Gradle Repository용 Artifactory와 Jenkins가 들어갑니다. 이 두개 서비스에 대해서는 일전 블로그 글로 설명한 적이 있으므로 별도로 설명할 것은 없다고 생각됩니다.

다만 아주 잉여롭지만 봇을 만든 겸에 빌드 시작 / 결과를 알려주는 별도의 Jenkins 호환용 그루비 스크립트를 작성해서 적용했습니다

Stack: Mail

기존 메일([email protected]) 는 Zoho Mail에서 수신/발신하도록 되어있어 내 것이 아니기도 한 존재였습니다.
따라서 이번 인스턴스에는 IMAP/SMTP 서버를 직접 구축하여 메일을 수신/발신할 수 있도록 설정하였습니다.

…다만, vultr가 SMTP 포트인 25번을 막아버리는 바람에 일단은 Gmail에 의존하여 발신하도록 설정하였으나 좀 더 Postfix를 공부하여 수정할 계획입니다.

메인 화면
글쓰기 화면

웹 메일 UI는 rainloop를 사용하였고, 특출나게 UI가 세련된 건 아니지만 나름대로 들어갈 기능은 전부 포함되어 있습니다.

Stack: Intro

물론 반응형입니다.

CSS Toolkit인 타키온(https://tachyons.io/) 을 이용하여 5~10분만에 대충 만든 인트로 페이지로, 이 서버에서 제공하는 서비스의 링크와 Github, Telegram, Contact 링크를 포함합니다.

물론, 이 인트로 페이지(라고 해도 HTML 1개 + CSS 1개) 도 docker 이미지를 만들어서 제공했으며, 도커 이미지는 https://github.com/WindSekirun/uzukilive-intropage 입니다.

Stack: Octobox

Octobox 라고 하는 Github의 알림을 관리하는 서비스가 있는데, 이 서비스가 self hosted로 하면 프라이빗 프로젝트까지 포함되어 설치하게 되었습니다.

이 서비스의 자세한 설명은 공식 홈페이지 (https://octobox.io/) 를 들어가는 것이 편리합니다.

마무리

일전에도 Jenkins나 Artifactory를 도커로 활용하고 있었지만 이번 기회를 통해 도커를 통한 관리가 얼마나 확장성과 자유성을 가져주는지 깨달았습니다.

개인 목적으로는 나름대로 규모가 있고, 따로 설치하라고 한다면 아마 멘탈 여러번 나가서 쉽게 던졌을텐데, 명령어 한 두줄 만으로 모든 것을 설정할 수 있다는 것이 큰 장점인 것 같습니다.

컨테이너가 14개가 되다보니 docker-compose로 각 스택별로 모아 필요한 스택만 켜고 끄고 할 수 있게 설정해두니, 패치할 때도 다운타임이 5~10분 내로 할 수 있다는 것도 좋았습니다.

열심히 가동되는 컨테이너(들)

그러면, 새로운 인스턴스와 새로운 환경에서 남은 6일, 앞으로 다가올 2019년에도 PyxisPub와 이 주인장(._. … )을 잘 부탁드립니다.