Jenkins-Kaniko 파이프라인 설계 선택 흐름

Jake·2026년 2월 8일

안녕하세요. 이번 글에서는 Jenkins 파이프라인을 구축하면서 어떤 문제를 겪었고, 그 상황에서 어떤 선택지를 두고 왜 해당 결정을 내렸는지에 대해 정리해보려고 합니다.

전 직장에서 Jenkins를 활용해 CI/CD 파이프라인을 운영하며 코드 통합 검증, 빌드, 배포를 진행한 경험이 있어 Jenkins 자체에 대한 이해와 사용 경험은 있는 상태였습니다.
다만 당시에는 이미 잘 구축된 환경을 사용하는 입장이었고, 이번 프로젝트에서는 처음부터 직접 설계하고 구축해야 하는 상황이었습니다.

현재 제 프로젝트에서는 아래와 같은 방식으로 수동 빌드 및 배포를 진행하고 있었습니다.

1. 코드 수정 및 로컬 빌드 (약 1분)
2. Dockerfile을 이용한 Docker Image 생성 (약 10분)
3. Docker Hub에 Image Push
4. Kubernetes Deployment의 Image / Tag 수동 수정
5. Pod 재생성 및 적용

전체 과정에 약 20분 정도가 소요되었고, 무엇보다 각 단계마다 상태를 확인하며 대기해야 했기 때문에 집중력이 끊기고, 반복 작업에 따른 피로도와 실수 가능성이 점점 커지고 있음을 느꼈습니다.
코드 수정 빈도가 늘어날수록 이러한 수동 배포 방식은 더 이상 효율적이지 않다고 판단하였고, 자연스럽게 빌드, 배포 자동화를 위한 CI/CD 파이프라인 구축의 필요성을 느끼게 되었습니다.

구현 과정에서는 주로 Gemini를 통해 초기 가이드를 확인하고, GPT와 Google 검색을 통해 교차 검증하는 방식으로 진행하였으며, 전체적으로 약 3시간 정도가 소요되었습니다.
특히 단순히 동작하는 방법보다는, 현재 인프라 환경에서 어떤 방식이 적절한지 판단하는 데에 더 많은 시간을 사용했습니다.

현재 인프라는 2대의 홈서버를 기반으로 Ubuntu와 Kubernetes를 운영 중이며, Master Node와 Worker Node로 구성된 비교적 단순한 클러스터 구조입니다.
이러한 환경적 제약 조건을 고려하여 Jenkins 배포 방식과 빌드 전략을 선택해야 했고, 이에 따라 여러 가지 선택지를 비교, 검토하게 되었습니다.

  • docker.sock 권한 문제, Kaniko, GPT 교차검증
  • CI와 CD를 어디까지 Jenkins가 맡아야 할까
  • 젠킨스 설치 위치
  • Ingress를 활용한 도메인 접속

선택지 고려

docker.sock 권한 문제, Kaniko, GPT 교차검증

Gemini 가 추천해준 yaml 파일과 스크립트를 검토하던 중, Jenkins 컨테이너가 호스트의 도커 데몬을 사용하기 위해 실제 워커노드에 sudo chmod 666 /var/run/docker.sock 으로 읽기, 쓰기 권한을 부여해야하는 구조를 확인했습니다.

하지만 도커 소켓에 대한 쓰기 권한은 호스트의 도커 데몬에 대한 전체 제어 권한을 의미하며 이는 클러스터 전체에 영향을 줄 수 있는 보안 리스크로 이어질 수 있다고 판단했습니다. 이에 따라 해당 방식이 적절한지 검증하기 위해 GPT 를 활용해 교차 검증을 진행하였고 그 결과 다음 2가지 대안을 정리할 수 있었습니다.

  1. Kaniko
  2. Jenkins k8s plugin + Pod Template 방식

Kaniko는 도커 데몬에 직접 접근하지 않고 컨테이너 내부에서 이미지를 빌드할 수 있어 도커 소켓을 마운트하지 않아도 되는 장점이 있습니다. 이를 통해 보안 위험을 줄이면서도 비교적 단순한 설정으로 이미지 빌드 파이프라인을 구성할 수 있었습니다.
반면 Jenkins k8s plugin + Pod Template 방식을 사용하면 Jenkins plugin 을 적극 활용할 수 있고 stage 마다 컨테이너를 분리할 수 있는 등 장점들이 많지만 이를 하기에는 러닝커브가 크고 여러 언어와 대규모 서비스가 아니므로 현재와 같이 간단한 빌드 스텝이 필요한 상황에서는 오버 엔지니어링이라고 판단했습니다.
다만 이후에 서비스가 커지고 여러 언어 빌드 환경이 필요하거나 stage 별 격리된 실행환경이 요구되면 2번 선택지를 충분히 검토해볼 것 같습니다.

CI와 CD를 어디까지 Jenkins가 맡아야 할까

현재 Jenkins 에서 애플리케이션을 빌드하고 rollout 명령어로 rolling update 방식으로 배포를 진행하고 있었습니다. 이 과정에서 Jenkins가 직접 클러스터에 접근하여 배포 명령을 수행하는 구조였고, 이에 따라 배포 흐름은 명령형 방식으로 비교적 직관적이고 빠르게 구현할 수 있었습니다.

다만 서비스 규모가 커질수록 Jenkins 에 클러스터 접근 권한이 집중되고, 현재 클러스터 상태가 선언된 상태와 일치하는지 지속적으로 추적하기 어렵다는 점과 배포 이력이 Jenkins 로그에만 남아 git 기준 변경 이력과 운영 상태를 한눈에 파악하기 어려운 점이 단점으로 다가왔습니다.
이를 위해 대안으로 ArgoCD 를 검토하게 되었습니다.
ArgoCD는 Git 변경사항을 감지하고 자동으로 배포되는 선언형 방식으로 동기화 되며 git 기반 변경 이력 관리와 배포 상태 가시성이 좋다는 장점들이 있다는 것을 알게되었습니다.

다만 현재 서비스 규모가 비교적 작고, 배포 빈도와 운영 복잡도가 높지 않은 상황에서는 ArgoCD 도입으로 인한 운영 오버헤드와 학습 비용이 더 크다고 판단하였습니다.
따라서 현 시점에서는 Jenkins 기반의 CD 구조를 유지하되, 서비스 규모 확장이나 멀티 클러스터 운영이 필요해지는 시점에 ArgoCD 도입을 검토하는 것이 합리적이라고 판단하였습니다.

젠킨스 설치 위치

Gemini 가 추천해준 방식은

  1. k8s 내부 설치
  2. OS 에 별도 설치

방법이었습니다. 저는 1번으로 진행하였는데 그 이유는 실제 Jenkins 를 통해 빌드를 하는 시간은 트리거가 될때만 동작하지만 OS 에 설치하게 되면 워커노드의 자원을 계속 점유하기 때문에 k8s 내부에 설치하여 젠킨스 관리 모듈만 가볍게 띄워놓고 빌드 트리거가 발생했을때에만 빌드용 Pod 를 통해 빌드하고 이후에 삭제되는 방식으로 자원관리를 하기 위해서였습니다.

여기서 Gemini가 Jenkins 설치를 위해 Helm 을 추천해줬지만 현재 k8s 에서는 yaml 파일로 관리되고 있어서 상황에 맞게 (이것때문에 Helm을 도입하기에는 비용이 크다고 판단) 이 부분은 직접 yaml 파일을 직접 정의하여 진행하였습니다.

Ingress를 활용한 도메인 접속

이후에 yaml 파일을 받아 k8s 에 설치하기 전 어떤 흐름으로 진행되는지 살펴보다가 Jenkins 도메인에 접속하기 위한 선택을 해야했습니다.

  1. Ingress를 활용하여 도메인으로 접속
  2. 공유기 포트포워딩 설정

선택지가 있었고 1번을 선택했습니다. 이유는 Ingress를 통해 https 를 사용하고 있었고 간단한 jenkins 경로 추가를 통해 접속할 수 있었기 때문입니다.

아래는 파이프라인 내용입니다.

pipeline {
    agent {
        kubernetes {
            yaml """
apiVersion: v1
kind: Pod
spec:
  # 필요 시 아래 SA 이름을 실제 RBAC 설정된 SA로 변경하세요.
  # serviceAccountName: jenkins-deployer

  containers:
  # Checkout 전용 (git 포함)
  - name: git
    image: alpine/git:2.44.0
    command: ["sleep"]
    args: ["999d"]

  - name: gradle
    image: gradle:8.4-jdk17-alpine
    command: ["sleep"]
    args: ["999d"]

  - name: kaniko
    image: gcr.io/kaniko-project/executor:debug
    command: ["sleep"]
    args: ["999d"]
    volumeMounts:
    - name: docker-config
      mountPath: /kaniko/.docker

  - name: kubectl
    image: bitnami/kubectl:latest
    command: ["sleep"]
    args: ["999d"]

  volumes:
  - name: docker-config
    secret:
      secretName: dockerhub-config
      # secret 타입이 kubernetes.io/dockerconfigjson 인 경우 key가 .dockerconfigjson인 경우가 많아
      # Kaniko가 읽는 표준 파일명(config.json)으로 매핑
      items:
      - key: .dockerconfigjson
        path: config.json
"""
        }
    }

    options {
        // Declarative 기본 checkout(자동 checkout) 방지: 우리가 만든 Checkout stage만 수행
        skipDefaultCheckout(true)
    }

    parameters {
        string(name: 'BRANCH_NAME', defaultValue: 'main', description: '빌드할 브랜치')
        // 필요 시 네임스페이스도 파라미터로 빼면 운영이 편합니다.
        string(name: 'K8S_NAMESPACE', defaultValue: 'default', description: '배포할 Kubernetes 네임스페이스')
    }

    environment {
        DOCKER_HUB_ID = 'rivkode'
        IMAGE_NAME = 'spring-app'
        TAG = "${BUILD_NUMBER}"
        REPO_URL = 'https://github.com/rivkode/language.git'

        // 아래 값은 실제 리소스 이름과 일치해야 합니다.
        DEPLOYMENT_NAME = 'spring-deployment'
        DEPLOY_CONTAINER_NAME = 'spring-app'
    }

    stages {
        stage('1. Checkout') {
            steps {
                container('git') {
                    checkout([$class: 'GitSCM',
                        branches: [[name: "*/${params.BRANCH_NAME}"]],
                        userRemoteConfigs: [[url: "${env.REPO_URL}", credentialsId: 'spring-github-auth']]
                    ])
                }
            }
        }

        stage('2. Build JAR') {
            steps {
                container('gradle') {
                    sh 'chmod +x ./gradlew'
                    // 캐시 효율을 위해 clean은 필요 시 제거 가능
                    sh './gradlew clean bootJar'
                }
            }
        }

        stage('3. Kaniko Build & Push') {
            steps {
                container('kaniko') {
                    sh """
                    /kaniko/executor \
                      --context ${env.WORKSPACE} \
                      --dockerfile ${env.WORKSPACE}/Dockerfile \
                      --destination ${env.DOCKER_HUB_ID}/${env.IMAGE_NAME}:${env.TAG} \
                      --destination ${env.DOCKER_HUB_ID}/${env.IMAGE_NAME}:latest
                    """
                }
            }
        }

        stage('4. CD (Rolling Update)') {
            steps {
                container('kubectl') {
                    sh """
                    kubectl -n ${params.K8S_NAMESPACE} set image deployment/${env.DEPLOYMENT_NAME} \
                      ${env.DEPLOY_CONTAINER_NAME}=${env.DOCKER_HUB_ID}/${env.IMAGE_NAME}:${env.TAG}
                    kubectl -n ${params.K8S_NAMESPACE} rollout status deployment/${env.DEPLOYMENT_NAME}
                    """
                }
            }
        }
    }
}

0개의 댓글