SUSE Rancher PoC — CI/CD Security Pipeline

Jenkins + NeuVector + Harbor
Secure CI/CD Pipeline

Maven 빌드 → Docker 이미지 → Harbor(general) 저장 → NeuVector 스캔 → 통과 시 Harbor(checked) 승격 + GitOps 업데이트

핵심 개념: 이미지를 두 개의 Registry 저장소로 분리한다. general은 빌드 직후 원본, checked는 보안 스캔 통과 후에만 입장 가능한 "프로덕션 허용 저장소"다. NeuVector Admission Control이 checked 이미지만 클러스터에 배포되도록 강제한다.

0 전체 파이프라인 흐름

각 스테이지를 클릭하거나 버튼으로 단계별 탐색

Maven Build
mvn package
🐳
Docker Build
+ Push general
🔍
NeuVector Scan
CVE 검사
Harbor Promote
general→checked
📝
GitOps Update
gitops-repo yaml
🚀
Fleet 배포
k8s-downstream
STEP 1 / 6

☕ Maven Build

소스 코드를 컴파일하고 JAR/WAR 아티팩트를 생성한다. mvn clean package -DskipTests — 테스트는 별도 stage에서 돌리거나 CI 초기 단계에서 처리.

1 Harbor 이중 저장소 패턴

SUSE Private Registry(Harbor)에 두 개의 Project를 만들어 스캔 통과 전/후를 분리한다

📦
general
빌드 직후 원본 이미지
스캔 미통과 포함
NeuVector Scan 통과 후만
🛡️
checked
보안 검증 완료 이미지
클러스터 배포 허용
주의: Harbor에서 "Project"가 generalchecked다. Jenkins가 빌드 후 harbor.example.com/general/myapp:v1.0에 푸시, 스캔 통과 후 Harbor API로 checked 프로젝트에 복사(artifact copy)한다.

Harbor API — Artifact 복사 (general → checked)

Harbor v2 API의 from 쿼리 파라미터로 프로젝트 간 이미지를 복사한다. 레이어를 재업로드하지 않고 메타데이터 참조만 복사되므로 빠르다.

POST
/api/v2.0/projects/checked/repositories/myapp/artifacts
?from=general/myapp:v1.0
general 프로젝트의 myapp:v1.0 아티팩트를 checked 프로젝트로 복사. HTTP 201 반환 시 성공.
GET
/api/v2.0/projects/general/repositories/myapp/artifacts?with_tag=true
general 프로젝트 이미지 목록 조회. 스캔 결과 포함 여부 확인 시 사용.
# Harbor Basic Auth (robot account 권장)
HARBOR_USER="robot$jenkins"
HARBOR_PASS="<robot-token>"
HARBOR_HOST="harbor.example.com"
IMAGE_TAG="${BUILD_NUMBER}"

# general → checked 복사
curl -sk -X POST \
  "https://${HARBOR_HOST}/api/v2.0/projects/checked/repositories/myapp/artifacts?from=general%2Fmyapp%3A${IMAGE_TAG}" \
  -u "${HARBOR_USER}:${HARBOR_PASS}" \
  -H "Content-Type: application/json" \
  -w "\n%{http_code}"

# 201 이면 성공, 나머지는 실패 처리
팁: from 파라미터 값은 URL 인코딩 필요. /%2F, :%3A. Jenkins pipeline에서 sh로 실행 시 curl 응답 코드 체크 후 실패 처리할 것.

2 NeuVector 이미지 스캔

두 가지 방법: Jenkins 공식 플러그인(권장) vs REST API 직접 호출

🔌 NeuVector Vulnerability Scanner Plugin

Jenkins 공식 플러그인으로 스캔 임계값 초과 시 빌드 자동 실패. 별도 스크립트 없이 선언적 파이프라인에서 사용 가능.

// Jenkinsfile — NeuVector Plugin 사용
stage('NeuVector Scan') {
    steps {
        neuvector(
            // Jenkins 시스템 설정에 등록한 NeuVector 컨트롤러 이름
            controllerEndpointUrlSelection: 'neuvector-prod',
            // NeuVector에 등록된 Harbor 레지스트리 이름
            registrySelection: 'suse-harbor-general',

            repository: "myapp",
            tag: "${BUILD_NUMBER}",

            // 스캔 대기 타임아웃 (분)
            scanTimeout: 10,

            // 임계값 초과 시 빌드 FAIL
            numberOfHighSeverityToFail: 5,   // High CVE 5개 초과 시 실패
            numberOfMediumSeverityToFail: 15,  // Medium CVE 15개 초과 시 실패

            // 특정 CVE 번호가 있으면 무조건 실패
            nameOfVulnerabilityToFailOne: 'CVE-2024-1234'
        )
    }
}
// 플러그인이 임계값 초과 감지 시 currentBuild.result = 'FAILURE' 자동 설정
사전 설정 필요: Jenkins → 시스템 설정 → NeuVector에서 컨트롤러 URL, 인증 정보 등록. NeuVector → Registry 설정에서 Harbor 레지스트리 등록 후 플러그인의 registrySelection에 그 이름 입력.
🔌 REST API 직접 호출

플러그인 없이 curl로 직접 NeuVector API를 호출하는 방식. 세밀한 제어 가능하지만 코드량이 많다.

Step 1 — 인증 토큰 취득

POST
/v1/auth
username/password → token 반환. 모든 이후 요청의 X-Auth-Token 헤더에 사용.
DELETE
/v1/auth
세션 종료. 최대 32개 동시 세션 제한 있으므로 사용 후 반드시 logout.

Step 2 — 온디맨드 이미지 스캔

POST
/v1/scan/repository
Long-poll 방식. 스캔 완료까지 응답 대기. 응답 본문에 CVE 목록, CVSS 점수 포함.
GET
/v1/scan/registry/{name}/images
레지스트리 스캔 결과 조회. 백그라운드 스캔 결과 확인 시 사용.
#!/bin/bash — NeuVector API 스캔 스크립트
NV_HOST="https://neuvector-svc.cattle-neuvector-system:10443"
HARBOR_HOST="harbor.example.com"

# 1. 인증
TOKEN=$(curl -sk -X POST "${NV_HOST}/v1/auth" \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin"}' \
  | jq -r '.token.token')

# 2. 스캔 요청 (long-poll — 완료까지 대기)
SCAN_RESULT=$(curl -sk -X POST "${NV_HOST}/v1/scan/repository" \
  -H "Content-Type: application/json" \
  -H "X-Auth-Token: ${TOKEN}" \
  --max-time 300 \
  -d "{
    \"request\": {
      \"registry\": \"https://${HARBOR_HOST}\",
      \"repository\": \"general/myapp\",
      \"tag\": \"${BUILD_NUMBER}\",
      \"username\": \"robot\$jenkins\",
      \"password\": \"${HARBOR_ROBOT_TOKEN}\",
      \"scan_layers\": true
    }
  }")

# 3. 결과 파싱
HIGH_COUNT=$(echo $SCAN_RESULT | jq '[.report.vulnerabilities[]|select(.severity=="High")]|length')
CRITICAL_COUNT=$(echo $SCAN_RESULT | jq '[.report.vulnerabilities[]|select(.severity=="Critical")]|length')

echo "High: ${HIGH_COUNT}, Critical: ${CRITICAL_COUNT}"

# 4. 로그아웃
curl -sk -X DELETE "${NV_HOST}/v1/auth" -H "X-Auth-Token: ${TOKEN}"

# 5. 임계값 판정
if [ $CRITICAL_COUNT -gt 0 ] || [ $HIGH_COUNT -gt 5 ]; then
  echo "SCAN FAILED: Critical=${CRITICAL_COUNT}, High=${HIGH_COUNT}"
  exit 1  # Jenkins 스테이지 실패 처리
fi

스캔 결과 판정 기준

Critical CVE 존재? OR High CVE > 임계값? OR 특정 CVE 번호 포함?

✅ PASS — 다음 단계 진행

Harbor checked 프로젝트로 이미지 복사
gitops-repo YAML 업데이트
Fleet 자동 배포 트리거

❌ FAIL — 빌드 실패

general 이미지 삭제 (선택)
개발팀에 CVE 목록 통보
클러스터에 배포되지 않음

NeuVector 스캔 결과 주요 필드: vulnerabilities[].severity (Critical/High/Medium/Low), name (CVE-YYYY-XXXXX), score (CVSS), package_name, package_version, fixed_version — fixed_version이 있으면 패키지 업그레이드로 해결 가능

3 전체 Jenkinsfile (선언적 파이프라인)

복사해서 바로 사용 가능한 구조. 변수값만 환경에 맞게 교체.

pipeline {
    agent any

    environment {
        HARBOR_HOST   = 'harbor.example.com'
        APP_NAME      = 'myapp'
        GITEA_HOST    = 'gitea.example.com'
        GITOPS_REPO   = 'test/gitops-repo'
        YAML_PATH     = 'k8s/deployment.yaml'
        IMAGE_TAG     = "${BUILD_NUMBER}"
    }

    stages {

        // ─── Stage 1: Maven Build ──────────────────────────────
        stage('Maven Build') {
            steps {
                sh 'mvn clean package -DskipTests'
            }
        }

        // ─── Stage 2: Docker Build + Push to general ───────────
        stage('Docker Build & Push (general)') {
            steps {
                withCredentials([usernamePassword(
                    credentialsId: 'harbor-robot-creds',
                    usernameVariable: 'HARBOR_USER',
                    passwordVariable: 'HARBOR_PASS'
                )]) {
                    sh '''
                        docker login -u "${HARBOR_USER}" -p "${HARBOR_PASS}" ${HARBOR_HOST}
                        docker build -t ${HARBOR_HOST}/general/${APP_NAME}:${IMAGE_TAG} .
                        docker push ${HARBOR_HOST}/general/${APP_NAME}:${IMAGE_TAG}
                    '''
                }
            }
        }

        // ─── Stage 3: NeuVector Scan ───────────────────────────
        stage('NeuVector Image Scan') {
            steps {
                neuvector(
                    controllerEndpointUrlSelection: 'neuvector-prod',
                    registrySelection:              'suse-harbor-general',
                    repository:                     "${APP_NAME}",
                    tag:                            "${IMAGE_TAG}",
                    scanTimeout:                    10,
                    numberOfHighSeverityToFail:     5,
                    numberOfMediumSeverityToFail:   15
                )
                // 임계값 초과 → 자동으로 빌드 FAILURE 처리됨
            }
        }

        // ─── Stage 4: Promote to checked ───────────────────────
        stage('Promote to checked (Harbor API)') {
            when {
                // 스캔 통과한 경우(SUCCESS)에만 실행
                expression { currentBuild.result == null || currentBuild.result == 'SUCCESS' }
            }
            steps {
                withCredentials([usernamePassword(
                    credentialsId: 'harbor-robot-creds',
                    usernameVariable: 'HARBOR_USER',
                    passwordVariable: 'HARBOR_PASS'
                )]) {
                    script {
                        // URL 인코딩: / → %2F, : → %3A
                        def fromParam = "general%2F${APP_NAME}%3A${IMAGE_TAG}"
                        def status = sh(
                            script: """
                                curl -sk -o /dev/null -w "%{http_code}" -X POST \\
                                  "https://${HARBOR_HOST}/api/v2.0/projects/checked/repositories/${APP_NAME}/artifacts?from=${fromParam}" \\
                                  -u "${HARBOR_USER}:${HARBOR_PASS}" \\
                                  -H "Content-Type: application/json"
                            """,
                            returnStdout: true
                        ).trim()
                        if (status != '201') {
                            error("Harbor promote failed: HTTP ${status}")
                        }
                        echo "✅ Image promoted to checked/${APP_NAME}:${IMAGE_TAG}"
                    }
                }
            }
        }

        // ─── Stage 5: GitOps Repo 이미지 태그 업데이트 ─────────
        stage('Update GitOps Repo') {
            when {
                expression { currentBuild.result == null || currentBuild.result == 'SUCCESS' }
            }
            steps {
                withCredentials([string(
                    credentialsId: 'gitea-token',
                    variable: 'GITEA_TOKEN'
                )]) {
                    sh """
                        rm -rf gitops-tmp
                        git clone https://${GITEA_TOKEN}@${GITEA_HOST}/${GITOPS_REPO}.git gitops-tmp
                        cd gitops-tmp

                        # deployment.yaml의 이미지 태그 업데이트
                        sed -i "s|image: ${HARBOR_HOST}/checked/${APP_NAME}:.*|image: ${HARBOR_HOST}/checked/${APP_NAME}:${IMAGE_TAG}|" ${YAML_PATH}

                        git config user.email "jenkins@ci.local"
                        git config user.name "Jenkins CI"
                        git add ${YAML_PATH}
                        git commit -m "ci: update ${APP_NAME} to ${IMAGE_TAG} [skip ci]"
                        git push https://${GITEA_TOKEN}@${GITEA_HOST}/${GITOPS_REPO}.git main
                    """
                }
            }
        }
    }

    post {
        always {
            sh 'docker rmi ${HARBOR_HOST}/general/${APP_NAME}:${IMAGE_TAG} || true'
            cleanWs()
        }
        failure {
            echo "❌ Pipeline failed at stage: ${currentBuild.result}"
        }
        success {
            echo "✅ Deployed: checked/${APP_NAME}:${IMAGE_TAG}"
        }
    }
}
gitops-repo YAML 구조 주의: sed -i 패턴이 정확히 매칭되어야 한다. YAML에서 이미지 라인이 image: harbor.example.com/checked/myapp:TAG 형태여야 교체 가능. 처음 구성 시 명시적 full image path 사용 권장.

4 gitops-repo YAML 구조

Fleet이 감시하는 gitops-repo의 Deployment YAML 예시 — Jenkins가 이미지 태그만 업데이트한다

# gitops-repo/k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: myapp-prod
spec:
  replicas: 2
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
      # OTel Java agent auto-injection (SUSE Observability)
      annotations:
        instrumentation.opentelemetry.io/inject-java: "true"
    spec:
      imagePullSecrets:
        - name: harbor-checked-secret  # checked 프로젝트 접근 시크릿
      containers:
        - name: myapp
          # ↓↓↓ Jenkins가 sed로 이 라인의 태그만 교체 ↓↓↓
          image: harbor.example.com/checked/myapp:42
          ports:
            - containerPort: 8080
# gitops-repo/fleet.yaml — Fleet GitOps 설정
defaultNamespace: myapp-prod

targetCustomizations:
  - name: downstream
    clusterSelector:
      matchLabels:
        management.cattle.io/cluster-display-name: k8s-downstream

# gitops-repo 최상위에 fleet.yaml 존재해야 Fleet이 인식
# git push 발생 → Fleet Controller가 변경 감지 → k8s-downstream에 적용
Fleet 동작: Jenkins가 gitops-repo에 commit + push → Gitea webhook 또는 Fleet polling으로 변경 감지 → Fleet이 k8s-downstream 클러스터에 새 이미지 태그로 Deployment 업데이트. Argo CD 없이 Fleet만으로 GitOps 완성.
1. Jenkins
gitops-repo clone → sed로 image: 라인 태그 교체 → git commit → git push
2. Gitea
commit 수신 후 저장. Fleet이 polling 또는 webhook으로 변경 감지
3. Fleet
gitops-repo의 변경 감지 → k8s-downstream에 매니페스트 적용 (kubectl apply)
4. k8s
Deployment 업데이트 → Rolling update → checked 이미지로 교체 완료
[skip ci] 주의: git commit 메시지에 [skip ci]를 붙이지 않으면 gitops-repo의 push가 Jenkins를 다시 트리거할 수 있다. Gitea webhook 설정에서 gitops-repo는 Jenkins를 트리거하지 않도록 분리 구성 권장.

5 NeuVector Admission Control

클러스터 레벨 배포 게이트웨이 — general 이미지를 클러스터에 직접 올리는 것 자체를 차단

NeuVector는 Kubernetes Validating Admission Webhook으로 동작한다. Pod 배포 요청이 들어오면 NeuVector Controller가 규칙을 검사하고 허용/거부를 결정한다.

kubectl apply API Server NeuVector Webhook ALLOW / DENY

권장 Admission Control 규칙 구성

ALLOW checked 이미지 + 스캔 완료 이미지만 허용
프로덕션 네임스페이스에 배포되는 이미지는 checked 저장소 출처 + 스캔 완료여야 한다.
image = harbor.example.com/checked/* imageScanned = true namespace = myapp-prod
DENY general 저장소 이미지 배포 차단
general 프로젝트 이미지가 실수로 프로덕션에 배포되는 것을 막는다.
image = harbor.example.com/general/* namespace = myapp-prod
DENY High CVE 초과 이미지 차단 (이중 안전망)
Jenkins 스캔 외에 NeuVector 자체 기준으로도 차단. 설정 오류나 우회 시도에 대한 방어선.
imageScanned = true cveHighCount > 5
Protect vs Audit 모드: 처음엔 Audit(모니터링 only)로 시작해 로그 확인 후 Protect(실제 차단)로 전환. Protect 모드 즉시 적용 시 기존 배포 파이프라인이 차단될 수 있음.

6 핵심 개념 퀴즈

클릭해서 뒤집기

Harbor에서 general → checked로 이미지를 복사할 때 레이어를 재업로드하는가?
클릭 →
Harbor Artifact Copy
아니다. Harbor API의 artifact copy(?from=)는 레이어를 재전송하지 않고 메타데이터 참조만 복사한다. 스토리지 효율적이고 빠르다.
NeuVector의 POST /v1/scan/repository는 비동기인가, 동기인가?
클릭 →
NeuVector Scan API
Long-poll 동기 방식. curl 요청이 스캔 완료까지 연결을 유지한다. --max-time을 충분히 크게(300초+) 설정해야 한다.
Jenkins에서 when { expression { ... } }의 역할은?
클릭 →
Declarative Pipeline
특정 조건이 참일 때만 해당 stage를 실행한다. 스캔 실패 시 currentBuild.result == 'FAILURE'가 되어 Promote/GitOps 단계를 건너뛴다.
NeuVector Admission Control의 imageScanned 조건이 필요한 이유는?
클릭 →
Admission Control
스캔되지 않은 이미지는 다른 CVE 조건을 회피할 수 있다. imageScanned=true가 없으면 미스캔 이미지가 CVE 기준을 통과한 것처럼 처리될 수 있다.
gitops-repo push 후 Fleet이 k8s-downstream에 변경을 적용하는 방식은?
클릭 →
Fleet GitOps
Fleet Controller가 gitops-repo를 주기적으로 polling하거나 webhook으로 변경을 감지. 변경된 매니페스트를 kubectl apply 방식으로 대상 클러스터에 적용한다.
NeuVector 세션 최대 동시 접속 수는? 초과 시 어떻게 되는가?
클릭 →
NeuVector API
사용자당 최대 32개 동시 세션. 초과 시 인증 실패. Jenkins 파이프라인에서 반드시 스캔 후 DELETE /v1/auth로 세션 정리해야 한다.

7 사전 설정 체크리스트

파이프라인 실행 전 완료해야 할 항목들

Harbor (SUSE Private Registry)

NeuVector

Jenkins + Gitea