
최근 Kubernetes 에서 Jenkins 멀티브랜치 파이프라인을 구축했습니다.
DinD, DooD 의 태생적인 보안 문제 때문에, 빌드를 어떻게 진행할지 고민이 많았는데요, Kaniko 를 통해 도커데몬 없이 컨테이너 이미지를 빌드할 수 있다는것을 알게되었습니다.
본 포스트에서는 Kubernetes 환경에서 Jenkins의 agent pod을 통해 kaniko를 활용한 캐시재사용성이 있는 CI 파이프라인을 구축하는 방법에 대해 다뤄보겠습니다.
kubernetes 환경에 Helm 으로 Jenkins를 배포한 경우, Jenkins는 기본적으로 kubernetes api 를 호출할 수 있는 권한을 갖게 되고, 각종 플러그인도 미리 설치가 됩니다:
ServiceAccount
Helm 차트는 기본적으로 jenkins라는 이름의 ServiceAccount를 생성하고 Jenkins Pod에 자동으로 주입합니다. 이 ServiceAccount는 Jenkins의 agent scheduling, config reloading 등의 Kubernetes 리소스 접근을 가능하게 합니다.
ClusterRoleBinding
jenkins-controller-cluster-admin이라는 이름으로 cluster-admin ClusterRole이 바인딩되어 있어, Jenkins 컨트롤러는 클러스터 전역의 리소스에 접근할 수 있는 권한을 가집니다. 이를 통해 Jenkins는 동적으로 agent Pod을 생성하고 상태를 추적할 수 있습니다.
Role / RoleBinding
Jenkins의 Helm chart는 jenkins-casc-reload, jenkins-schedule-agents와 같은 이름의 Role 및 RoleBinding을 자동으로 생성하여, Jenkins Pod가 네임스페이스 내에서 필요한 리소스(configmap 등)에 접근할 수 있도록 합니다.
사전 설치된 플러그인들
Helm chart는 Kubernetes plugin (kubernetes), workflow 관련 플러그인(workflow-aggregator), Git 관련 플러그인(git, git-client) 등 Jenkins 운영에 필요한 필수 플러그인들을 함께 설치합니다. 이를 통해 별도 설정 없이 Kubernetes 기반의 Dynamic Agent 실행과 Git 기반 멀티브랜치 파이프라인 구성이 가능합니다.
저는 Kustomization + Helm 방식으로 Jenkins를 클러스터에 배포하였는데요, 사양은 다음과 같습니다.
kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: jenkins
helmCharts:
- name: jenkins
repo: https://charts.jenkins.io
releaseName: jenkins
version: 5.8.66
namespace: jenkins
valuesFile: base/values.yaml
Jenkins는 kubernetes api를 통해 Agent를 생성할때, 스탠드얼론 노드에서 동작할때와는 다른 방식으로 Agent를 관리합니다.
먼저 Jenkins는 jnlp 가 포함된 이미지로 jnlp 컨테이너를 가진 Pod를 띄웁니다. 그럼 jnlp 프로토콜을 통해 Jenkins master pod과 비로소 연결이 되어, Agent 측이 파이프라인에 대한 명령을 수신할 수 있게됩니다.
기본적으로, 아무런 설정을 하지 않으면 Jenkins는 자동으로 Agent Pod에 jnlp 컨테이너를 딱 하나만 띄웁니다. 그리고 emptyDir인 workspace-volume 을 /home/jenkins/agent 에 마운트합니다.
그리고 그 안의 서브디렉토리로 /home/jenkins/agent/workspace/{파이프라인이름}이 WORKSPACE 환경변수로 지정됩니다.
기본적인 jnlp 이미지로도 충분하지만, 만약 커스텀된 jnlp 이미지를 사용하고 싶다면, pod 정의서에 기입하는 경우 오버라이딩할 수 있습니다.
이를 간단하게 다이어그램으로 나타내면 아래와 같습니다.
시퀀스 다이어그램
아키텍처 다이어그램
그리고 가장 골격이 되는 기본 pod 정의서를 표현해보자면, 다음과 같습니다.
apiVersion: v1
kind: Pod
metadata:
labels:
jenkins: agent
spec:
containers:
- name: jnlp
image: jenkins/inbound-agent:latest
args: ["$(JENKINS_SECRET)", "$(JENKINS_NAME)"]
env:
- name: JENKINS_SECRET
valueFrom:
fieldRef:
fieldPath: metadata.annotations['jenkins.io/secret']
- name: JENKINS_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: workspace-volume
mountPath: /home/jenkins/agent
volumes:
- name: workspace-volume
emptyDir: {}
exec java -jar /usr/share/jenkins/agent.jar "$@" 입니다.이전 목차에서 Jenkins Agent 의 전신이 되는 기본 Pod 정의서를 살펴보았습니다. 여기에서 우리는 kaniko를 사용하기 위해 정의서를 커스터마이징 해야하는데요, 바로 다음과 같습니다.
apiVersion: v1
kind: Pod
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:debug
command: ["sleep"]
args: ["3600"]
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
readOnly: true
volumes:
- name: docker-config
secret:
secretName: regcred
items:
- key: .dockerconfigjson
path: config.json
네, jnlp container와 workspace-volume은 생략해도 저절로 생성이 되기때문에, 반드시 필요한 kaniko 만 정의했습니다.
말씀드릴 부분이 많은데요, 위에서부터 차례로 설명드리겠습니다.
이미지
kaniko 이미지로 gcr.io/kaniko-project/executor:debug를 사용했는데요, 왜냐하면 기본 이미지에서는 sh, sleep 과 같은 커맨드들이 포함되어 있지 않기 때문입니다.
우리는 파드가 생성될때 바로 kaniko executor 를 실행하는 것이 아닌, 파이프라인 진행 도중에 kaniko 를 실행하기 위해 kaniko의 기본 entryPoint인 executor를 우회하면서도, 컨테이너가 종료되지 않게 해야 합니다.
그래서 sleep 커맨드가 포함된 debug 이미지를 사용했습니다.
그러나 제 견해로는, 따로 기본 이미지에 필요한 커맨드를 추가한 커스텀 이미지를 따로 빌드하는것이 더 나을것이라고 생각합니다.
regcred
kaniko는 컨테이너 레지스트리에 이미지를 푸시해야하기 때문에, 인증정보가 필요합니다. 일반적으로 Kubernetes에서는 kuberctl create secret docker-registry 명령어를 사용해 .dockerconfigjson 포맷의 시크릿을 생성하는데,
이 시크릿은 다음과 같이 생겼습니다:
kubectl create secret docker-registry regcred \
--docker-username=<your-username> \
--docker-password=<your-password> \ # 여기는 진짜 비밀번호가 아닌, Access Token을 넣어야합니다.
--docker-email=<your-email>
이때 생성된 시크릿은 내부적으로 data[".dockerconfigjson"] 키를 포함합니다.
kaniko는 기본적으로 /kaniko/.docker/config.json 경로에서 인증 정보를 찾습니다. 따라서 위에서 생성한 시크릿을 Secret Volume 으로 마운트할 때, key 이름인 .dockerconfigjson을 config.json으로 변경해서 mountPath에 맞게 매핑해주어야 합니다.
다시 말해, 아래와 같이 volumeMounts 와 volumes 설정을 구성함으로써, kaniko가 해당 인증 정보를 올바른 위치에서 참조할 수 있도록 해야 합니다.
그것이 바로:
volumes:
- name: docker-config
secret:
secretName: regcred
items:
- key: .dockerconfigjson
path: config.json
이렇게 Jenkinsfile을 만든 후, 모든것이 완벽해 보였습니다만, 치명적인 문제가 있었습니다. 이는 kaniko에 대한 저의 잘못된 이해에서 발생했는데요...
저는 여기에 gradle cache 전용 PVC를 하나 붙여주면, kaniko가 빌드를 진행할때마다 해당 cache를 사용하지 않을까? 라고 생각했습니다.
그것은 완전히 잘못된 것으로, kaniko는 PVC를 빌드에서 애초에 사용할 수 없는 구조였습니다. 왜냐하면 새로운 컨테이너환경을 자체적으로 생성하기 때문인데요, 이 컨테이너환경은 Pod의 그것과는 다릅니다.
kaniko는 실제로 Pod 내부 컨테이너의 파일시스템을 그대로 사용하는 것이 아니라, 자체적인 user-space build 환경을 생성하여 그 안에서 build context를 unpack하고, Dockerfile을 해석해 이미지 레이어를 구성합니다.
즉, 우리가 Jenkins Agent Pod에 PersistentVolumeClaim을 마운트해서 공유하고자 해도, kaniko는 그 볼륨의 내용을 단순히 COPY 하거나, build context로 읽을 수는 있지만, 내부적으로 캐시 쓰기/읽기 용도로 활용하진 못한다는 점입니다.
그래서 저는 결국...! kaniko 의 빌드 역할을 분리하여 따로 빼내기로 했습니다. 바로 Jenkins Pod에 gradle-build라는 이름의 빌드전용 컨테이너를 만들어서 PVC를 붙이고, 거기서 빌드를 수행하는 방법이었습니다.
그래서 수정된 Pod yaml은 다음과 같습니다:
apiVersion: v1
kind: Pod
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:debug
command: ["sleep"]
args: ["3600"]
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
readOnly: true
- name: gradle
image: gradle:8.4.0-jdk17-alpine
command: ["sleep"]
args: ["3600"]
volumeMounts:
- name: gradle-cache
mountPath: /home/gradle/.gradle
volumes:
- name: docker-config
secret:
secretName: regcred
items:
- key: .dockerconfigjson
path: config.json
- name: gradle-cache
persistentVolumeClaim:
claimName: gradle-cache
이렇게 제 CI 파이프라인에서 gradle 캐시가 정상적으로 동작할 수 있게 되었습니다.
하지만, Kaniko가 사용하는 캐시는 어떨까요? 먼저, Kaniko가 캐시를 사용하는 방법에 대해 알아봅시다.
이쯤에서 의문이 들 수 있습니다.
"Gradle은 PVC 캐시를 잘 쓰는데, 왜 Kaniko는 안 되는 걸까?"
사실 둘 모두 Kubernetes에서 실행되는 컨테이너이고, 둘 다 디스크 캐시를 활용한다는 점에서 구조적으로 유사해 보입니다. 그러나 다음과 같은 근본적인 차이가 있습니다:
| 항목 | Gradle | Kaniko |
|---|---|---|
| 캐시 목적 | 네트워크 기반 의존성(예: Maven Central) 재사용 | 이미지 레이어 중복 방지 |
| 실행 방식 | 컨테이너 내부에서 직접 gradle build 수행 |
내부적으로 user-space 환경에서 Dockerfile 해석 및 실행 |
| 캐시 디렉토리 제어 | .gradle 경로에 직접 접근 가능 |
/kaniko 내부 파일시스템은 독립적이며 외부 PVC와 연동 불가 |
| PVC 캐시 가능성 | Agent Pod에 마운트한 PVC를 반복 재사용 가능 | 레이어는 레지스트리에 저장되므로 PVC 접근 자체가 불필요 |
| 캐시 일관성 문제 | Git SHA, 브랜치 등 조건만 관리하면 안정적 | 컨텍스트와 Dockerfile에 따라 레이어 해시가 달라져 무용지물 |
즉, Gradle은 빌드 시점에 직접 특정 디렉토리에 의존성 데이터를 쓰고, 그것을 다시 읽는 구조이기 때문에 PVC 기반의 로컬 캐시 활용이 크게 의미 있습니다.
반면, Kaniko는 레지스트리 기반의 레이어 저장소를 사용하는 이미지 빌더이며, 모든 빌드는 새로운 유저 스페이스 컨텍스트에서 수행되므로 PVC로 캐시를 유지하는 방식은 구조적으로 부적절합니다.
따라서 Kaniko는 반드시 --cache-repo를 사용하는 방식으로 캐시 전략을 가져가야 하며, PVC는 context 공유나 output 전달 용도로만 쓰는 것이 좋습니다.
kaniko는 Dockerfile 빌드 중 생성되는 이미지 레이어를 캐싱하여, 중복된 빌드 작업을 생략하고 빠르게 이미지를 재생성할 수 있는 기능을 제공합니다. 하지만 이 캐시는 PVC나 로컬 디스크가 아닌, 레지스트리 기반 캐시 저장소를 통해 관리됩니다.
kaniko에서 캐시를 사용하려면 다음과 같은 옵션을 executor에 추가해야 합니다:
/kaniko/executor \
--context="${CONTEXT}" \
--dockerfile="${DOCKERFILE}" \
--destination="${IMAGE}" \
--cache=true \
--cache-repo=example/example-api-cache
| 옵션명 | 설명 |
|---|---|
| --cache | 캐시 사용 여부 (true) |
| --cache-repo | 캐시된 레이어를 저장할 레지스트리 경로 |
이렇게 설정하면 kaniko는 Dockerfile을 해석할 때, 각 명령어별 레이어에 대한 digest를 계산하고, 해당 digest가 cache-repo에 존재하는지 확인한 뒤, 있다면 재사용하고 없다면 새로 빌드하여 저장합니다.
💡 레이어 캐시란?
Dockerfile은 명령어 한 줄 한 줄이 각각 하나의 이미지 레이어(layer) 로 변환된다. 예를 들어 다음과 같은 Dockerfile이 있다고 하자:FROM ubuntu:20.04 RUN apt-get update && apt-get install -y curl COPY . /app RUN make /app위 Dockerfile은 다음과 같은 4개의 레이어를 생성하게 된다:
- FROM ubuntu:20.04
- RUN apt-get update && apt-get install -y curl
- COPY . /app
- RUN make /app kaniko는 각 레이어를 실행한 결과를 digest 해시로 기록하고, 이 해시를 기반으로 캐시된 레이어가 레지스트리에 존재하는지 확인한다. 만약 동일한 Dockerfile을 다시 빌드하게 될 경우, 변경되지 않은 레이어는 --cache-repo에서 재사용되고, 변경된 레이어 이후만 새로 빌드하게 된다. 이때 명령어 순서또한 중요하다. 순서가 달라지면 캐시 미스가 난다!!
Jenkinsfile
pipeline {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:debug
command: ["sleep"]
args: ["3600"]
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
readOnly: true
- name: gradle
image: gradle:8.4.0-jdk17-alpine
command: ["sleep"]
args: ["3600"]
volumeMounts:
- name: gradle-cache
mountPath: /home/gradle/.gradle
volumes:
- name: docker-config
secret:
secretName: regcred
items:
- key: .dockerconfigjson
path: config.json
- name: gradle-cache
persistentVolumeClaim:
claimName: gradle-cache
"""
defaultContainer 'kaniko'
}
}
environment {
DOCKERFILE = "${WORKSPACE}/Dockerfile"
CONTEXT = "${WORKSPACE}"
REPO_URL = 'https://github.com/example/example-repo.git'
BRANCH = 'prod'
GITHUB_CREDENTIAL_ID = 'github-credentials'
}
stages {
stage('Check out') {
steps {
container('jnlp') {
sh "git config --global --add safe.directory ${WORKSPACE}"
dir("${WORKSPACE}") {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${BRANCH}"]],
userRemoteConfigs: [[
url: REPO_URL,
credentialsId: GITHUB_CREDENTIAL_ID
]]
])
}
}
}
}
stage('Extract Version') {
steps {
container('kaniko') {
script {
def version = sh(
script: "grep '^version' ${WORKSPACE}/build.gradle | awk '{print \$3}' | tr -d \"'\"",
returnStdout: true
).trim()
env.VERSION = version
env.IMAGE = "example/example-api:${version}"
echo "📦 Detected version: ${version}"
}
}
}
}
stage('Gradle Build') {
steps {
container('gradle') {
dir("${WORKSPACE}") {
sh '''
export GRADLE_USER_HOME=/home/gradle/.gradle
echo "▶ GRADLE_USER_HOME=$GRADLE_USER_HOME"
gradle build -x test --no-daemon --build-cache
'''
}
}
}
}
stage('Build & Push with Kaniko') {
steps {
container('kaniko') {
sh """
echo '▶ Using version: ${VERSION}'
ls -al ${WORKSPACE}
find ${WORKSPACE} -name build.gradle
cat /kaniko/.docker/config.json
/kaniko/executor \
--context=${CONTEXT} \
--dockerfile=${DOCKERFILE} \
--destination=${IMAGE} \
--cache=true \
--cache-repo=example/example-api-cache
"""
}
}
}
}
post {
always {
echo '✅ Job complete. Pod will be garbage collected automatically.'
}
}
}
Dockerfile
# Runtime-only image (Gradle build happens in Jenkins before Kaniko)
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# Jenkins Gradle stage will produce exactly one JAR under build/libs/
COPY build/libs/*.jar app.jar
# Run as non-root for better security
RUN addgroup -S app && adduser -S app -G app
USER app
ENTRYPOINT ["java", "-jar", "app.jar"]
결론적으로, 1분 38초 -> 28초로 빌드시간을 약 70%정도 감축할 수 있었습니다.
Cache 전
Cache 후
