Skip to main content

코드 리뷰 가능한 Jenkins? - 패키징 가능한 코드 기반의 Jenkins 환경 구축

· 23 min read
VSFe
블로그 주인장

이번 주 화요일에, 사내 기술 공유 세션인 NAVER ENGINEERING DAY에서 기술 발표를 진행했다. 물론 정확히는 라이브가 아니고 영상이었지만 ㅎㅎ;;

다만 발표 준비 시점에 너무 업무 및 기타 개인 일정으로 많이 바빴고, 다소 허겁지겁 자료를 만들고 영상을 찍다보니 내용 누락도 있었고 여러모로 아쉬움이 컸던 것 같다.

원래는 외부 영상 업로드까지 신청해두긴 했지만, 업무적인 내용이 포함되어 있어 만약 실제로 올라가게 된다면 발표 자료 재제작 및 영상 재촬영이 필수인 상황이라, 전반적으로 상황을 알 수 없게 되었고 (ㅜㅜㅜ), 아쉬운대로 우선 블로그에 좀 더 꼼꼼하게 내용을 공유해보려고 한다. (혹시 이후에 해당 주제의 영상이 업로드 되었다면, 아마 이 블로그에 적힌 내용과 유사할 것이다.)

왜 Jenkins를?

현재 업무를 하고 있는 부서는 목적과 실제 지역을 기반으로, 리전을 구분하고 있다. (동일한 대한민국 지역이라고 해도, 내부 서비스와 외부 서비스에 따라 리전은 분리되는 느낌이라고 보면 좋다.) 거기에 개발, 운영 환경이 있으니 더더욱 관리해야 하는 리전은 많을 수 밖에 없었다.

각 리전은 db나 배치등이 분리될 수 밖에 없고, ACL 및 네트워크 정책등을 고려하면 자연스럽게 하나의 리전에 하나의 Jenkins가 대응되어야 할 것이다.

문제는... 상술한 것 처럼 리전이 많은 환경이다보니, 스케쥴링 트리거 목적의 Jenkins가 10개 이상 필요했다는 것. 이에 아래와 같은 문제들이 있었다.

  • 유지보수 문제. Job을 추가하거나 수정해야 하는 경우, 약 10개 정도의 Jenkins에 일일히 추가하고 수정해야 했다. 그래도 추가라면 존재하지 않은 경우 금방 눈치챌 수 있으나, 수정을 제대로 했는지 확인하기 위해선 직접 들어가서 세부 설정을 보지 않은 이상 수정 여부를 확인하기 어려웠다. 그러다보니 잦은 휴먼 에러가 발생했고, 심지어 1년 넘게 수정이 안 되었던 케이스도 존재했었다.
  • 신규 리전 구축 문제. 현재 부서는 수십개의 스케쥴링 잡을 관리하고 있는 만큼, 새로운 리전에 Jenkins를 구축하기 위해선 설정 및 잡 추가를 모두 수행해야 한다. 대안으로 Job Import 플러그인이 있긴 하지만, Tomcat의 설정을 일부 변경해야 하며, (해당 플러그인은 Jenkins API를 호출하는 구조로 되어 있는데, 이 과정에서 기본 설정으로는 막혀있는 문자가 포함된 쿼리를 요청한다.) 막상 가져온다고 해도 각 리전 및 dev/real 에 대응하는 prefix 등에 대한 수정이 필요하다.

특히나, 상위 조직 단위에서 올해 컨테이너 환경 전환을 추진하게 되면서 자연스럽게 Jenkins도 컨테이너로 전환이 필요했고, 이는 이전까지 구축한 VM Jenkins 들을 전부 밑바닥부터 전부 재구축해야 하는 상황이 된 것이다. 이 과정에서, 누군가 재미있는 제안을 했다.

코드 리뷰 가능한 Jenkins는 어떨까요?

만약 모든 잡을 코드로 관리한다면, 매번 GUI로 수동으로 추가할 필요 없이 코드리뷰를 통해 잡을 적용할 수 있지 않을까?

즉, 우리는

  • 코드 리뷰가 가능한 Jenkins Job 구조
  • 모든 것들을 패키징해 컨테이너 배포 시 바로 사용 가능한 Jenkins 구조 확립

을 만들기로 했다.

#1 - Jenkins CasC (Configuration as a Code)

가장 먼저, Jenkins CasC에 대해 알아보도록 하자. 뒤에서 설명할 온갖 도구들과 비교하여, 해당 도구는 제법 잘 알려진 도구인 만큼, 간략하게 설명하기로 하겠다.

Jenkins CasC 는 Jenkins의 모든 설정 을 코드로 관리하기 위한 도구이다. 잡이 아니라는 점에 유의하라.

jenkins:
systemMessage: "Jenkins configured automatically by Jenkins Configuration as Code plugin\n\n"
globalNodeProperties:
- envVars:
env:
- key: VARIABLE1
value: foo
- key: VARIABLE2
value: bar
securityRealm:
ldap:
configurations:
- groupMembershipStrategy:
fromUserRecord:
attributeName: "memberOf"
inhibitInferRootDN: false
rootDN: "dc=acme,dc=org"
server: "ldaps://ldap.acme.org:1636"

위와 같은 일반적인 yaml 형식의 구조를 띄고 있으며, 각각의 트리 구조는 실제 설정에서의 폴더 구조와 동일하다.

다만 일반적으로 직접 CasC 파일을 하나하나 다 작성하진 않고, 보통 기존 사용중인 Jenkins 인스턴스에서 CasC 파일을 추출해서 사용하는 방식을 많이 쓴다. (즉, 설정을 복사하여 타 Jenkins 에서 빠르게 적용할 수 있도록 하는 것이 목적이다.)

아래와 같은 사항만 유의하면, Jenkins CasC를 사용하는 데에는 큰 어려움이 없을 것이다.

  • CasC 플러그인이 설치된 경우, Jenkins는 CASC_JENKINS_CONFIG 라는 환경변수의 값을 읽는다.
    • 만약 컨테이너화 등 패키징을 수행하는 것이 목적이라면, 배포 스크립트 내 CASC_JENKINS_CONFIG를 반드시 설정해야 한다.
  • CasC 파일의 값도 환경변수를 사용할 수 있다. (Jenkins 부팅 과정에서 해당 파일을 읽는 구조이므로, 환경변수를 필드의 value로 넣을 수 있다.)
  • 만약 로드된 CasC 파일에서 해당 Jenkins 인스턴스에 없는 설정 값이 있다면, 에러를 띄우고 부팅되지 않는다.
    • 즉, 해당 파일을 추출한 Jenkins 인스턴스에 설치된 플러그인과 관련된 설정이 있다면, 해당 설정 값을 지우고 Import 하거나 아예 처음부터 플러그인 설치를 같이 수행해야 한다.
    • 만약 컨테이너화 등 패키징을 수행하는 것이 목적이라면, Jenkins에서 제공하는 Plugin Installation Manager Tool 을 쓰는 것도 좋다.
    • 부서내 자체 플러그인이나 기타 사유로 Plugin Installation Manager Tool 에서 설치가 불가하다면, 아예 목록에 파일을 포함하는 것도 방법이다. (우리 부서의 경우엔 사내 메신저용 알림 도구 때문에 이런 케이스가 존재했다.)

#2 - Jenkins Job DSL (실패)

일단 설정은 해결했고, 그래서 잡을 어떻게 코드로 관리할 것인가?

플러그인을 뒤져보다보니 Job을 DSL로 관리하는 플러그인이 있었다. Jenkins Job DSL 이라는 플러그인이었다.

이름만 들어봐도 알겠지만 DSL 방식으로 잡을 추가할 수 있는 플러그인이다. 예를 들어, 아래와 같은 DSL을 작성할 수 있다.

job("sendNoti") {
description("알림 발송")
keepDependencies(false)
disabled(false)
concurrentBuild(false)
steps {
shell("/home1/irteam/scripts/launchJob.sh sendNoti")
}
displayName("(DEV) KR3_SERVICE_sendNoti")
configure {
it / 'properties' / 'jenkins.model.BuildDiscarderProperty' {
strategy {
'daysToKeep'('10')
'numToKeep'('-1')
'artifactDaysToKeep'('-1')
'artifactNumToKeep'('-1')
}
}
}
}
  • 기본적인 설정을 제외한 추가 설정은 configure 블록에 포함된다.
    • 플러그인과 관련한 설정이 필요하다면, it / 'properties' / 'plugin 패키지 명' 식으로 접근 가능하다.
  • job의 id와 displayName 이 별도로 존재한다.
  • Jenkins CasC와 동일하게, 해당 DSL 구문도 기존에 등록된 Job을 DSL로 변환하는 기능이 있으니, 처음이라면 해당 방법을 사용해서 export 해도 좋다.

특징이라면, 하나의 DSL 파일에서 여러 잡을 동시에 생성하는 것도 가능하다. (코드 하나에 job(~~) 구문이 여러개라면, 여러 잡을 등록할 수 있다.)

우리의 목표가 너무 쉽게 달성되는 것 아니야? 싶어서 다소 김이 샐 뻔 했지만... 사실 김 샐 게 아니긴 하지만. 아래와 같은 문제로 우리의 상황에선 직접 사용할 수 없었다.

  • 기본적으로, Job DSL 의 목적은 동적으로 Job을 생성하기 위한 것이다.
    • 일반적으로 Seed Job 목적의 Jenkins Project를 생성하고, 해당 프로젝트에서 DSL을 실행시키는 방식으로 잡을 생성해야 한다.
  • Jenkins의 정책 상, Groovy Script 가 Sandbox 바깥에서 실행된다면 반드시 관리자 권한을 갖는 사용자의 승인이 필요하다. (심지어 사용자가 관리자라고 하더라도, 형식적인 승인을 해야 한다.)
    • 이는 Jenkins 의 보안 정책에 의한 것으로, 보안 설정을 조작한다면 무시할 순 있지만, 기본값은 승인 필수이다.

그러나 우리의 목표는 Jenkins가 뜨자마자 바로 Job이 등록되는 구조를 원했기 때문에, 아쉽게도 이 도구를 직접적으로 사용할 순 없었다.

#2.5 - 잠깐, Jenkins의 LifeCycle을 들여다보자.

Jenkins 는 부팅 과정에서, 여러 상태를 갖는다. Jenkins를 이를 InitMilestone 이라고 하며, 아래와 같은 상태가 존재한다.

상태설명
STARTED말 그대로 막 시작한 시점
PLUGINS_LISTED, PLUGINS_PREPARED, PLUGINS_STARTED플러그인 메타데이터와 의존성 검사가 완료된 시점/메타데이터 및 클래스로더가 준비된 시점/시작된 시점
EXTENSIONS_ARGUMENTED코드로 정의된 Extension Point 구현체가 Jenkins에 추가된 시점 (참고)
SYSTEM_CONFIG_LOADED, SYSTEM_CONFIG_ADAPTEDCasC 등으로 인해 적용되는 설정 값들이 로드되고, 필요에 따라 업데이트까지 수행한 시점
JOB_LOADED, JOB_CONFIG_ADAPTED디스크에서 Job 정보가 로드되고, 플러그인 업데이트 등으로 인해 설정이 변경된 것이 반영된 시점
COMPLETED부팅 과정에서의 가장 마지막 시점

이 순서를 인지하면, 앞에서 왜 CasC 설정에 플러그인이 없을 때 에러를 잡을 수 있는지 인지할 수 있다.

Jenkins 관련 플러그인을 개발하게 되면 저 InitMilestone 에 대응하여 특정 시점에 어떤 행동/명령을 수행할 수 있도록 만들 수 있다.

뒤에서 다른 이야기를 할 때 이 LifeCycle이 활용될 것이므로, 우선 가볍게만 이해하도록 하자.

#3 - Jenkins Job DSL + Initialization Script

어쨌거나 Jenkins Job DSL은 컨셉 자체는 상당히 좋아보였기에, 조금 다른 생각을 해봤다.

우리가 어떻게든 강제로 실행시키게 할 순 없을까?

Jenkins Initialization Script

Jenkins 로드가 완료된 이후 실행되는 추가 정의 스크립트로, 사용자가 원하는 작업을 부팅 시점에 실행시키도록 하는 스크립트이다.

  • $JENKINS_HOME/init.groovy 파일이나 $JENKINS_HOME/init.groovy.d/ 폴더 하위 groovy 스크립트가 실행된다.
  • 앞에서 언급한 Job DSL 과 달리 외부 사용자가 생성한 파일이 아니므로, Jenkins 보안 정책의 영향을 받지 않는다.

즉, 어떻게든 Job DSL을 실행할 방법을 찾는다면, 이걸 init.groovy.d 에 집어넣어 부팅 시점에 강제로 실행하도록 유도할 수 있을 것이다.

Job DSL에 대한 고민

Jenkins Project 에서 입력한 Job DSL은 결국 groovy 언어 형식을 따르고 있다. 그렇다면 어딘가에서 해당 groovy 코드를 읽고, 실행하는 클래스가 있지 않을까? 그렇다면 우리는 이 클래스를 강제로 로드하여 Job을 등록할 수 있을 것이다.

final String workspacePath = "/home1/irteam/scripts/jobs"
final String dslScriptPath = "${workspacePath}/sendNoti.groovy" // 1
final File dslScriptFile = new File(dslScriptPath)

try {
final String jobDslScript = dslScriptFile.text // 2

def jobManagement = new JenkinsJobManagement(System.out, [:], new File(workspacePath)) // 3
new DslScriptLoader(jobManagement).runScript(jobDslScript) // 4
} catch (Throwable e) {
println "[BOOT][ERROR] JobDSL: failed to run script: ${e.class.name}: ${e.message}"
e.printStackTrace()
jenkins.setSystemMessage("...")
}
  1. sendNoti.groovy 파일의 경로를 의미한다. 상술한 Job DSL 예시와 완전히 동일한 파일이다.
  2. Job DSL 플러그인이 스크립트를 구동할 수 있도록, 텍스트 형식으로 파일을 전부 읽어들인다.
  3. JenkinsJobManagement 를 생성한다. 해당 클래스 또한 Job DSL 플러그인의 클래스이다.
  4. 해당 클래스의 runScript 메서드를 통해, 강제로 Job DSL을 파싱하여 잡을 등록하도록 유도한다.

해당 스크립트를 구성하게 되면, Jenkins 가 부팅되는 시점에 sendNoti.groovy가 실행될 것이고, 따라서 코드로 Jenkins Job을 모두 관리할 수 있게 된다!

#4 - 더욱 추상화하기

하지만 여전히 아쉬운 점이 많았다.

  • 수십개의 스케쥴링 잡을 관리해야 하는 시점에, 각각의 잡을 하나의 groovy 파일로 관리하게 되면, 파일을 관리하는 입장에서도 다소 번거로울 수 있다.
  • 대부분의 잡은 기본 설정은 동일하고, (ex. 실패 시 동작하는 Hook, 실행 방식 등) 일부 값 및 파라미터만 차이가 있다.
  • 잡 이름에 붙는 Prefix 구조를 생성하기 위한 메서드 또한 (위에서 sendNoti 에 대한 displayName을 (DEV) KR3_SERVICE_sendNoti 로 했다는 것을 기억해보자.) 모든 잡에 공통적으로 적용될 것이다.

그렇다면, 아예 템플릿화 시키면 어떨까?

위에서 Job DSL을 설명할 때 언급했던 내용을 잠깐 떠올려보자.

특징이라면, 하나의 DSL 파일에서 여러 잡을 동시에 생성하는 것도 가능하다. (코드 하나에 job(~~) 구문이 여러개라면, 여러 잡을 등록할 수 있다.)

아예 극단적으로 모든 잡이 공유하는 단 하나의 DSL 템플릿 파일을 만들고, 새로운 잡을 추가하는 경우엔 별도의 config 파일만 건드리도록 해볼 수 있을 것이다.

jobs:
- name: sendNoti
type: SERVICE
description: '알림 발송'
schedule: '*/2 * * * *'

- name: correctData
type: SERVICE
description: '데이터 정합성 보정'
schedule: 'H 2 * * *'

- name: fileStat
type: STAT
description: '파일 업로드, 다운로드 통계'
schedule: 'H 3 * * *'
parameter: 'isWrite=true'

아예 이렇게 만든다면 어떨까? 필요한 파라미터만 yaml 에 기록한다면? 이 경우, 아래와 같이 DSL 을 실행하는 시점에 yaml 을 같이 들고오면 된다.

def yamlData = new Yaml().load(new File(YAML_PATH).text)

yamlData.jobs.each { jobConfig ->
// .. 데이터 꺼내는 작업
final String jobNameForDisplay = buildDisplayName(JOB_PREFIX, jobConfig, isUnused)

job(jobConfig.jobName) {
displayName(jobNameForDisplay)
description(jobConfig.description)
// 중략
}
}

즉, 우리는

  • init.groovy.d 디렉토리에 포함되는 초기화 스크립트 (DslScriptLoader 로 Job DSL 을 수행하기 위한 목적)
  • Job DSL 템플릿
  • 설정 정보를 담은 yaml 파일 하나

총 3개의 파일로, 수십개의 설정을 모두 관리할 수 있었다.

#5 - 추가 고민해야 할 부분

어떻게 yaml 에 없는 잡을 삭제할 수 있죠?

결국 Job DSL의 원래 목적을 생각해보면, 삭제는 애초에 염두에 두지도 않았다. 그야 DSL은 보통 생성에 초점에 맞춰져 있기 때문일 것이다.

다만 어차피 yaml 에 각각의 잡의 id가 명시되어 있고, Jenkins 에서도 잡의 목록을 땡겨올 수 있으니, 아래와 같은 코드를 추가하면 쉽게 처리할 수 있다.

def allJobs = jenkins.getAllItems(Job.class)

allJobs.each { job ->
if (!expectedJobNames.contains(job.name)) {
job.delete()
// println "[INFO] Deleting unused job: ${job.name}"
}
}

DSL이 수행되는 동안 잡이 실행되면 어떡하죠?

잡의 설정을 수정하거나 제거해야 한다고 가정 해보자. 그런데 그 작업을 처리하는 중에 Jenkins 부팅이 끝나서 잡이 수정/삭제 되기 전에 실행이 되어버린다면? 상당히 곤란해 질 수 있다.

아니, 애초에 그런 경우가 생길 수 있긴 한가?

이 문제에 대한 답을 하기 전, 앞에서 언급한 Jenkins의 LifeCycle을 다시 떠올려보자. 그렇다면, init.groovy.d는 어느 시점에 실행되는가?

// core/src/main/java/hudson/init/impl/GroovyInitScript.java
public class GroovyInitScript {
@Initializer(after=JOB_CONFIG_ADAPTED)
public static void init(Jenkins j) {
new GroovyHookScript("init", j.servletContext, j.getRootDir(), j.getPluginManager().uberClassLoader).run();
}
}

코드를 보면 알 수 있지만, 정답은 JOB_CONFIG_ADAPTED 상태 이후이다. 즉 COMPLETED 가 되어 Jenkins 의 잡이 실행되기 직전에 동작하는 스크립트이기 때문에, 운이 지지리도 없다면 스크립트 완료전에 잡이 실행될 수 있다.

따라서 시작과 동시에 Jenkins를 Prepared for Shutdown 상태로 만들어버린다면, 잡 실행을 원천적으로 봉쇄할 수 있을 것이다.

Jenkins j = Jenkins.get()
j.doQuiteDown()
j.setSystemMessage("[${ts()}] [BOOT] Jenklins is in QUITE DOWN mode. Preparing jobs via Job DSL...")

// Job Initialization 작업 수행

j.doCancelQuiteDown()

결론

해당 사항은 실제로 부서에 적용된 내용으로, 신규 Job이 추가되거나 수정이 필요하다면 yaml 파일만 코드리뷰 대상에 넣어 모두가 변경사항을 캐치할 수 있게 하였다.

  • 휴먼 에러가 사실상 제로가 되었으며, 배포만 하면 모든 Job의 수정사항이 반영되므로 운영 서버 또한 수정 타이밍을 조정할 수 있었다.
  • 신규 리전 구축 시 추가 작업 없이 환경변수만 교체하여 컨테이너 이미지를 새로 생성하는 것 만으로 모든 작업을 완료할 수 있었다. (GUI 작업 0)

또한, 이런 경험을 발표까지 할 수 있게 되어서 전반적으로 매우 재밌는 경험이었던 것 같다.

작은 도구를 쓰더라도, 어떻게 하면 우리 조직의 상황에 맞춰서 커스텀 할 수 있을지 늘 고민이 필요할 것 같기도 하다. ㅎㅎ;;