Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

jenkins

Tung Bui Thanh (TungDaDev)10 mins read

Jenkins.webp

CI/CD là thứ đi cùng code từ lúc commit đến khi chạy trên production. Còn nói tới orchestration (điều phối) cho cả luồng đó thì Jenkins vẫn là cái tên quen thuộc nhất.

Mình không dừng ở cú pháp, mà đi qua bản chất, kiến trúc và vài pattern hay dùng, kèm mấy cái bẫy hay gặp khi vận hành Jenkins.

# bản chất

Nhiều người nghĩ Jenkins chỉ để "chạy test và build code". Thực ra Jenkins là một open-source automation server. Nó không tự làm hết mọi thứ mà điều phối (orchestrate) các công cụ khác (Maven, Docker, Kubernetes, SonarQube...) để ghép thành một Pipeline hoàn chỉnh.

Điểm mạnh nhất của Jenkins là Plugin-based Architecture. Với hơn 2.000 plugin (trang chủ jenkins.io ghi 2300+), nó tích hợp được với gần như mọi công nghệ, nên hay được dùng làm "Hub" trung tâm cho việc tự động hóa. Mặt trái là plugin cũng là nguồn lỗ hổng bảo mật và xung đột phiên bản lớn nhất; cài càng ít plugin càng đỡ khổ lúc nâng cấp.

# kiến trúc

Để CI/CD không thành nút thắt cổ chai (bottleneck) khi dự án lớn dần, Jenkins chia kiến trúc thành các phần rõ ràng:

jenkins-structure
  • Controller (Master): là "bộ não". Nó quản lý cấu hình, lên lịch (schedule) các job, giao tiếp với người dùng qua UI và phân phối công việc. Nguyên tắc: không chạy build trực tiếp trên Controller (đặt số executor của built-in node về 0), để khỏi cạn tài nguyên và giảm rủi ro bảo mật: build chạy trên controller đọc được cả thư mục JENKINS_HOME, nơi chứa credentials đã mã hoá cùng khoá giải mã.
  • Agents (Nodes/Slaves): là những "công nhân" chạy job. Muốn scale horizontally thì thêm agent (chạy trên Linux, Windows, hoặc spin-up tự động dưới dạng Docker containers/Kubernetes pods).
  • Executors: số luồng (slots) trên mỗi agent, quyết định có bao nhiêu build chạy song song trên agent đó.

# pipeline as code (jenkinsfile)

Giờ không ai click chuột cấu hình Job trên UI nữa. CI/CD nên được quản lý như source code: version hóa và review. Jenkins có Declarative Pipeline, một cú pháp Groovy kiểu khai báo, dễ đọc và dễ bảo trì.

Đây là một Pipeline mẫu, gồm các khâu Checkout, Build, Unit Test, Code Quality, Build Docker và Deploy lên Kubernetes. Điều kiện when { branch ... } chỉ có tác dụng trong Multibranch Pipeline (xem phần dưới); junit, jacoco, withSonarQubeEnv, docker.build, slackSend, cleanWs đều đến từ plugin riêng, phải cài trước:

pipeline {
    agent any
 
    environment {
        MAVEN_OPTS = '-Xmx512m'
        NEXUS_CREDS = credentials('nexus-credentials')
    }
 
    options {
        timeout(time: 30, unit: 'MINUTES') // Ngăn chặn treo job vĩnh viễn
        disableConcurrentBuilds()          // Đảm bảo tính nhất quán khi deploy
        buildDiscarder(logRotator(numToKeepStr: '10')) // Tối giản không gian lưu trữ (Digital Danshari)
    }
 
    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }
 
        stage('Build') {
            steps {
                sh './mvnw clean package -DskipTests -s settings.xml'
            }
        }
 
        stage('Unit Tests') {
            steps {
                sh './mvnw test -s settings.xml'
            }
            post {
                always {
                    junit '**/target/surefire-reports/*.xml'
                    jacoco(execPattern: '**/target/jacoco.exec')
                }
            }
        }
 
        stage('Code Quality') {
            parallel {
                stage('SonarQube Analysis') {
                    steps {
                        withSonarQubeEnv('sonar') {
                            sh './mvnw sonar:sonar'
                        }
                    }
                }
                stage('Security Scan (Fortify)') {
                    steps {
                        sh 'scancentral -url ${FORTIFY_URL} start -bt mvn'
                    }
                }
            }
        }
 
        stage('Build & Push Docker Image') {
            when { branch 'main' }
            steps {
                script {
                    def image = docker.build("registry/backend-service:${env.BUILD_NUMBER}")
                    docker.withRegistry('https://registry.example.com', 'docker-creds') {
                        image.push()
                        image.push('latest')
                    }
                }
            }
        }
 
        stage('Deploy to Staging') {
            when { branch 'develop' }
            steps {
                sh 'kubectl apply -f k8s/staging/ --namespace=staging'
            }
        }
 
        stage('Deploy to Production') {
            when { branch 'main' }
            input {
                message "Duyệt deploy lên Production?" // Manual Gate
                ok "Deploy"
            }
            steps {
                sh 'kubectl apply -f k8s/prod/ --namespace=prod'
            }
        }
    }
 
    post {
        success {
            slackSend(channel: '#deployments', message: "✅ [${env.JOB_NAME}] Build #${env.BUILD_NUMBER} thành công. Chuẩn bị release.")
        }
        failure {
            slackSend(channel: '#deployments', message: "[${env.JOB_NAME}] Build #${env.BUILD_NUMBER} thất bại. Cần kiểm tra ngay.")
        }
        always {
            cleanWs() // Dọn dẹp Workspace để tránh leak data và tốn dung lượng
        }
    }
}

Pipeline mẫu này có vài chỗ nên chỉnh khi dùng thật:

  • input giữ executor: vì có agent any ở cấp pipeline, trong lúc chờ người bấm "Deploy" thì job vẫn chiếm một executor trên agent, có khi chờ cả buổi. Thêm nữa, timeout 30 phút tính cả thời gian chờ duyệt. Cách thường làm là để agent none ở cấp pipeline, khai báo agent cho từng stage, còn stage duyệt thì đặt input không kèm agent và bọc timeout riêng.
  • Image tag: stage build đẩy image tag BUILD_NUMBER, nhưng kubectl apply -f k8s/prod/ chỉ áp dụng manifest có sẵn trong repo. Nếu manifest trỏ tới latest thì Kubernetes có thể không kéo image mới (pod không đổi spec thì không rollout). Nên thay tag trong manifest trước khi apply (kustomize edit set image, helm --set image.tag=... hoặc kubectl set image). Nhánh develop cũng không build image nên staging chỉ apply lại manifest cũ.
  • jacoco step thuộc plugin JaCoCo đã cũ; bản mới khuyên dùng plugin Coverage với step recordCoverage.

# giải quyết bài toán scale

# multi-branch pipeline

Thay vì tạo tay từng Job cho từng nhánh, Multibranch Pipeline tự scan Git Repository. Thấy Jenkinsfile là nó tự tạo Pipeline tương ứng.

  • main: tự chạy luồng Build + Deploy Production.
  • develop: chạy luồng Build + Deploy Staging.
  • feature/X hoặc PR: chỉ chạy Build, Unit Test và Security Scan.

Cơ chế đằng sau là mỗi nhánh thành một job con, và biến BRANCH_NAME (hoặc CHANGE_ID với PR) được set sẵn, nên các điều kiện when { branch 'main' } trong Jenkinsfile mới có nghĩa. Nhánh bị xoá thì job con cũng được dọn theo cấu hình "orphaned item strategy".

# shared libraries

Hệ thống lớn (ví dụ microservices với hàng chục services cùng stack Java/Spring Boot) mà copy-paste Jenkinsfile thì bảo trì rất khổ.

Shared Libraries đưa nguyên tắc DRY (Don't Repeat Yourself) vào CI/CD: bạn gom logic chung vào một thư viện dùng chung:

// File: vars/springBootPipeline.groovy (Đặt trong Repo Shared Library)
def call(Map config = [:]) {
   pipeline {
       agent any
       stages {
           stage('Build') {
               steps {
                   sh "./mvnw clean package -P${config.profile ?: 'dev'} -s settings.xml"
               }
           }
           stage('Test') {
               steps {
                   sh './mvnw test -s settings.xml'
               }
           }
           stage('Publish') {
               when { expression { config.publish } }
               steps {
                   sh './mvnw deploy -DskipTests -s settings.xml'
               }
           }
       }
   }
}
 
// File: Jenkinsfile (Nằm trong repo của từng Microservice)
@Library('my-shared-library') _
springBootPipeline(profile: 'dev', publish: true)

Tên hàm gọi trong Jenkinsfile chính là tên file trong thư mục vars/ (springBootPipeline.groovy → springBootPipeline(...)). Một file chỉ nên chứa nguyên một khối pipeline { } như trên; Jenkins hỗ trợ định nghĩa cả Declarative Pipeline trong shared library, nhưng chỉ ở dạng nguyên khối, không trộn được một nửa pipeline từ library với một nửa từ Jenkinsfile.

Nhờ vậy pipeline gọn hơn, và muốn nâng cấp logic CI/CD cho tất cả service thì chỉ sửa một chỗ. Mặt trái cũng ở đó: sửa library là ảnh hưởng mọi service cùng lúc. Nên gắn version cho library (@Library('my-shared-library@v1.4') _) và nâng dần, thay vì để mọi service luôn chạy nhánh main của library.

# credentials management

Không hardcode API Keys, Passwords hay Certificates vào code. Jenkins có Credentials Management để lo việc này. Để ý các lệnh sh bên dưới đều dùng nháy đơn: biến như $PASS được shell đọc từ biến môi trường lúc chạy. Nếu dùng nháy kép ("curl -u ${USER}:${PASS}") thì Groovy thay giá trị secret vào chuỗi lệnh trước, secret có thể lộ trong log hoặc danh sách process, và Jenkins sẽ cảnh báo "insecure interpolation of sensitive variables":

// Ẩn giấu cấu hình xác thực tinh tế
withCredentials([usernamePassword(
   credentialsId: 'nexus-creds',
   usernameVariable: 'USER',
   passwordVariable: 'PASS')]) {
   sh 'curl -u $USER:$PASS https://nexus/...'
}
 
// Secret text
withCredentials([string(credentialsId: 'api-key', variable: 'API_KEY')]) {
   sh 'curl -H "Authorization: Bearer $API_KEY" ...'
}
 
// SSH key
withCredentials([sshUserPrivateKey(
   credentialsId: 'deploy-key',
   keyFileVariable: 'SSH_KEY')]) {
   sh 'ssh -i $SSH_KEY user@server "deploy.sh"'
}
 
// File
withCredentials([file(credentialsId: 'kubeconfig', variable: 'KUBECONFIG')]) {
   sh 'kubectl --kubeconfig=$KUBECONFIG apply -f ...'
}

# triggers

pipeline {
   triggers {
       // Poll SCM every 5 minutes
       pollSCM('H/5 * * * *')
 
       // Cron schedule
       cron('H 2 * * *')  // daily at ~2am
 
       // Webhook (GitLab/GitHub push)
       gitlab(triggerOnPush: true, triggerOnMergeRequest: true)
   }
}

Ví dụ trên gom cả ba kiểu cho dễ nhìn, thực tế chọn một: có webhook thì bỏ pollSCM (poll liên tục chỉ tốn tải cho Git server). Chữ H trong cron là "hash": Jenkins rải giờ chạy theo tên job để hàng trăm job không cùng khởi động đúng một phút. Trigger gitlab(...) cần plugin GitLab; với GitHub thì dùng githubPush() hoặc cấu hình webhook ở Multibranch Pipeline.

# docker in jenkins

pipeline {
   agent {
       docker {
           image 'maven:3.9-eclipse-temurin-21'
           args '-v $HOME/.m2:/root/.m2'  // cache dependencies
       }
   }
   stages {
       stage('Build') {
           steps {
               sh 'mvn clean package'
           }
       }
   }
}

# gitlab ci vs jenkins

AspectJenkinsGitLab CI
ConfigJenkinsfile (Groovy - lập trình được logic phức tạp).gitlab-ci.yml (YAML - khai báo tĩnh, cấu trúc chặt)
HostingSelf-hostedBuilt into GitLab
Plugins2000+ pluginsÍt hơn, chủ yếu tính năng built-in và CI/CD components
FlexibilityRất cao (Groovy scripting), hợp với workflow orchestration phức tạpStructured, less flexible - tốt cho các flow chuẩn, chuẩn hóa nhanh
MaintenanceCần nguồn lực vận hành Server, update Plugins, vá lỗi bảo mậtGần như không nếu dùng gitlab.com; self-managed vẫn phải vận hành
DockerPlugin-basedNative Docker support
ScalingNode/Agent mạnh nhưng cần setup cẩn thậnRunner-based, hỗ trợ Docker/K8s native sẵn

# best practices

Vài nguyên tắc để vận hành Jenkins cho trơn tru:

  • Pipeline as Code: Jenkinsfile nên nằm cùng repo code và được version hóa.
  • Tách Controller và Agent: giữ Controller nhẹ, việc nặng đẩy xuống Agents.
  • Dùng Docker Agent: đừng cài thẳng JDK, Node.js hay Maven lên Agent. Dùng Docker Image (VD: maven:3.9-eclipse-temurin-21) làm môi trường build, để tránh "Works on my machine" hoặc xung đột phiên bản giữa các job.
  • Luôn set Timeout: để job bị treo (deadlock) không âm thầm ngốn tài nguyên.
  • Dọn dẹp (Clean Workspace): thêm bước cleanWs() ở cuối pipeline, đỡ các lỗi khó hiểu do cache cũ để lại.
  • Dùng Parallel Stages: các khâu độc lập như Unit Test, quét SonarQube, check lỗ hổng Fortify... cho chạy song song để dev đỡ phải chờ.

# common patterns

# maven build với settings dùng chung

configFileProvider (plugin Config File Provider) lấy file settings.xml lưu trên Jenkins ra một file tạm, nên khỏi commit settings.xml chứa thông tin repository nội bộ vào từng repo. Muốn cache dependency giữa các build thì vẫn phải mount thư mục .m2 như phần Docker ở trên.

stage('Build') {
   steps {
       configFileProvider([configFile(fileId: 'maven-settings', variable: 'MAVEN_SETTINGS')]) {
           sh "./mvnw clean package -s $MAVEN_SETTINGS -DskipTests"
       }
   }
}

# conditional deployment

stage('Deploy') {
   when {
       allOf {
           branch 'main'
           not { changeRequest() }
       }
   }
   steps { ... }
}

# matrix builds (multiple jdk versions)

stage('Test') {
   matrix {
       axes {
           axis {
               name 'JDK'
               values '17', '21'
           }
       }
       stages {
           stage('Test') {
               agent { docker { image "eclipse-temurin:${JDK}" } }
               steps { sh './mvnw test' }
           }
       }
   }
}

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.