Hướng dẫn Setup Jenkins cho hệ thống Monolith + Bài học 35 + Xây dựng Jenkins Shared Libraries để tái sử dụng mã nguồn Pipeline và chuẩn hóa quy trình
Ở Bài 34, chúng ta đã dùng JCasC (jenkins.yaml) để biến toàn bộ cấu hình của máy chủ Jenkins thành mã nguồn gọn gàng. Tuy nhiên, hãy nhìn lại tệp Jenkinsfile trong dự án Monolith của chúng ta sau 30 bài học: Nó đã phình to lên hơn 200 dòng code!
Vấn đề thực tế sẽ phát sinh ngay khi công ty của bạn có từ 2 đến 3 dự án Monolith (ví dụ: Monolith Core E-commerce, Monolith ERP Nội bộ, Monolith CRM), hoặc có hàng chục nhánh feature/* cùng tồn tại:
-
Nếu bạn copy-paste 200 dòng
Jenkinsfilenày sang cả 3 dự án, khi cần đổi định dạng tin nhắn Telegram (Bài 19) hoặc nâng cấp tham số quét bảo mật Trivy (Bài 16), bạn sẽ phải đi sửa tay ở tất cả các kho chứa code và từng nhánh Git! -
Tệ hơn nữa, lập trình viên trong lúc sửa code nghiệp vụ có thể vô tình xóa nhầm dòng
waitForQualityGatetrongJenkinsfilecủa nhánh họ để lách luật kiểm duyệt!
Bài học hôm nay sẽ hướng dẫn bạn kỹ thuật đỉnh cao trong lập trình Pipeline: Jenkins Shared Libraries — Đóng gói toàn bộ logic phức tạp thành thư viện dùng chung theo nguyên tắc DRY (Don't Repeat Yourself), giúp rút gọn tệp Jenkinsfile từ 200 dòng xuống chỉ còn đúng 10 dòng khai báo!
1. Bản chất và Cấu trúc thư mục chuẩn của một Jenkins Shared Library
Jenkins Shared Library thực chất là một kho chứa Git độc lập (ví dụ: jenkins-monolith-shared-lib.git) do đội ngũ DevOps nắm quyền quản lý. Khi một Pipeline chạy, Jenkins sẽ tự động tải kho thư viện này về trước và nạp các hàm chức năng vào bộ nhớ để Jenkinsfile gọi ra như những câu lệnh có sẵn.
Một kho chứa Shared Library bắt buộc phải tuân thủ cấu trúc 3 thư mục chuẩn sau:
Plaintext
jenkins-monolith-shared-lib/ (Git Repository riêng của team DevOps)
├── vars/ <-- QUAN TRỌNG NHẤT: Chứa các hàm toàn cục (Global Steps) gọi trực tiếp trong Jenkinsfile
│ ├── notifyTelegram.groovy (Gọi trong Jenkinsfile bằng lệnh: notifyTelegram(...))
│ ├── runMonolithTests.groovy (Gọi trong Jenkinsfile bằng lệnh: runMonolithTests(...))
│ ├── buildAndScanDocker.groovy (Gọi trong Jenkinsfile bằng lệnh: buildAndScanDocker(...))
│ └── monolithStandardPipeline.groovy (Đóng gói nguyên cả 1 Pipeline hoàn chỉnh thành 1 hàm duy nhất!)
├── src/ <-- Chứa các Class Groovy hướng đối tượng (Helper/DTO phức tạp)
│ └── com/yourcompany/ci/
│ └── VersionHelper.groovy
└── resources/ <-- Chứa các tệp tĩnh không phải code Groovy (Bash scripts, JSON templates, SQL)
└── scripts/
└── deploy-blue-green.sh (Có thể lưu luôn script deploy ở đây để nạp động!)
| Thư mục trong Shared Library | Khi nào nên sử dụng? | Cách gọi trong Jenkinsfile |
|---|---|---|
vars/*.groovy |
Dùng cho 90% nhu cầu thực tế: Viết các bước thực thi (sh, withCredentials, sshPublisher) hoặc tạo ra một khung Pipeline chuẩn (Pipeline Template). |
Tên tệp là notifyTelegram.groovy thì trong Jenkinsfile chỉ cần gọi thẳng: notifyTelegram(status: 'SUCCESS'). |
src/**/*.groovy |
Khi cần viết các thuật toán xử lý dữ liệu thuần túy bằng ngôn ngữ Groovy/Java (tính toán mảng, parse JSON phức tạp). | Phải dùng từ khóa import com.yourcompany.ci.VersionHelper ở đầu file. |
resources/** |
Lưu trữ các tệp mẫu cấu hình (sonar-project.properties), payload JSON của Discord/Slack hoặc tệp deploy-blue-green.sh. |
Đọc nội dung tệp ra chuỗi String bằng hàm libraryResource('scripts/deploy-blue-green.sh'). |
2. Thực hành Bước 1: Tách các hàm lẻ vào thư mục vars/ (Custom Steps)
Hãy tạo một Git Repository mới cho đội DevOps và lần lượt chuyển các đoạn code lặp đi lặp lại trong Jenkinsfile của chúng ta vào thư mục vars/:
A. Tệp vars/notifyTelegram.groovy (Chuẩn hóa thông báo ChatOps từ Bài 19)
Khi viết một tệp trong thư mục vars/, hàm chính bắt buộc phải đặt tên là def call(Map config = [:]). Nhờ nhận tham số kiểu Map, người dùng có thể truyền các cặp key: value rất trực quan:
Groovy
// Tệp: vars/notifyTelegram.groovy
def call(Map config = [:]) {
def status = config.status ?: 'SUCCESS'
def appName = config.appName ?: env.APP_NAME
def version = config.version ?: env.BUILD_VERSION
def branch = config.branch ?: env.CLEAN_BRANCH
def gitAuthor = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true).trim()
def gitMessage = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim().replaceAll('<', '<').replaceAll('>', '>')
def duration = currentBuild.durationString.replace(' and counting', '')
def statusIcon = (status == 'SUCCESS') ? "✅" : ((status == 'UNSTABLE') ? "⚠️" : "❌")
withCredentials([
string(credentialsId: 'telegram-bot-token', variable: 'TG_TOKEN'),
string(credentialsId: 'telegram-chat-id', variable: 'TG_CHAT_ID')
]) {
sh """
curl -s -X POST "https://api.telegram.org/bot\${TG_TOKEN}/sendMessage" \\
-d chat_id="\${TG_CHAT_ID}" \\
-d parse_mode="HTML" \\
-d disable_web_page_preview=true \\
--data-urlencode text="${statusIcon} <b>[${status}] ${appName}</b>
━━━━━━━━━━━━━━━
🏷 <b>Version:</b> <code>${version}</code>
🌿 <b>Branch:</b> <code>${branch}</code>
👤 <b>Author:</b> ${gitAuthor}
💬 <b>Commit:</b> <i>${gitMessage}</i>
⏱ <b>Duration:</b> ${duration}
━━━━━━━━━━━━━━━
🔗 <a href='${env.BUILD_URL}console'>Console Log</a> | <a href='${env.BUILD_URL}testReport'>Test Report</a>" > /dev/null || true
"""
}
}
B. Tệp vars/buildAndPushDocker.groovy (Đóng gói, Quét Trivy và Push Image từ Bài 25)
Groovy
// Tệp: vars/buildAndPushDocker.groovy
def call(Map config = [:]) {
def imageFullUrl = config.imageUrl
def buildVersion = config.version
def gitCommit = config.commit
def registryUrl = config.registry ?: 'registry.yourcompany.com'
def registryCred = config.credId ?: 'docker-registry-credentials'
def cacheRoot = config.cacheDir ?: '/var/jenkins/cache'
echo "🐳 [SharedLib] Đang đóng gói Docker Image đa tầng: ${imageFullUrl}"
sh """
DOCKER_BUILDKIT=1 docker build \\
--pull \\
--build-arg BUILD_VERSION=${buildVersion} \\
--build-arg GIT_COMMIT=${gitCommit} \\
-t ${imageFullUrl} \\
-f Dockerfile .
"""
echo "🛡️ [SharedLib] Quét lỗ hổng bảo mật Container bằng Trivy..."
sh """
trivy image \\
--cache-dir ${cacheRoot}/trivy \\
--severity HIGH,CRITICAL \\
--ignore-unfixed \\
--exit-code 1 \\
${imageFullUrl}
"""
echo "🚀 [SharedLib] Đẩy Image lên Private Registry: ${registryUrl}"
withCredentials([usernamePassword(
credentialsId: registryCred,
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS'
)]) {
sh """
echo "\$REG_PASS" | docker login ${registryUrl} -u "\$REG_USER" --password-stdin
docker push ${imageFullUrl}
docker logout ${registryUrl}
"""
}
}
3. Thực hành Bước 2 (Đỉnh cao): Đóng gói nguyên cả Declarative Pipeline vào 1 hàm (vars/monolithStandardPipeline.groovy)
Tại sao chỉ dừng lại ở việc tách từng hàm nhỏ? Trong các tổ chức có quy chuẩn DevSecOps chặt chẽ, Tech Lead muốn khóa cứng thứ tự các bước bắt buộc (Checkout -> Unit Test -> SonarQube -> Build Docker -> Approval -> Blue/Green Deploy), còn từng dự án Monolith chỉ được phép truyền vào các thông số riêng của dự án đó (như appName, dockerRepo, phpVersion, approvers).
Trong Jenkins, bạn hoàn toàn có thể đặt toàn bộ khối pipeline { ... } vào bên trong hàm def call(Map cfg = [:]) của tệp vars/monolithStandardPipeline.groovy:
Groovy
// Tệp: vars/monolithStandardPipeline.groovy
def call(Map cfg = [:]) {
pipeline {
agent none
options {
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '3'))
timeout(time: 60, unit: 'MINUTES')
timestamps()
disableConcurrentBuilds()
}
environment {
APP_NAME = "${cfg.appName}"
DOCKER_REGISTRY = "${cfg.dockerRegistry ?: 'registry.yourcompany.com'}"
DOCKER_REPO = "${DOCKER_REGISTRY}/${cfg.projectGroup ?: 'monolith'}/${APP_NAME}"
PROD_APPROVERS = "${cfg.prodApprovers ?: 'admin'}"
CACHE_ROOT = "/var/jenkins/cache"
}
stages {
stage('1. CI: Checkout, Test & SonarQube Gate') {
agent { label "${cfg.agentLabel ?: 'monolith-worker'}" }
steps {
checkout([
$class: 'GitSCM',
branches: scm.branches,
extensions: [[$class: 'CloneOption', depth: 50, noTags: false, shallow: true]],
userRemoteConfigs: scm.userRemoteConfigs
])
script {
env.GIT_SHORT_HASH = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim()
env.LATEST_SEMVER_TAG = sh(script: 'git describe --tags --abbrev=0 2>/dev/null || echo "v1.0.0"', returnStdout: true).trim()
env.CLEAN_BRANCH = sh(script: 'git rev-parse --abbrev-ref HEAD', returnStdout: true).trim().replaceAll('[^a-zA-Z0-9.-]', '-')
if (env.CLEAN_BRANCH in ['main', 'master']) {
env.TARGET_ENV = 'production'
env.SSH_SERVER_NAME = cfg.prodSshServer
env.ENV_CRED_ID = cfg.prodEnvCredId
} else {
env.TARGET_ENV = 'staging'
env.SSH_SERVER_NAME = cfg.stagingSshServer
env.ENV_CRED_ID = cfg.stagingEnvCredId
}
env.BUILD_VERSION = "${env.LATEST_SEMVER_TAG}-${env.TARGET_ENV}.b${env.BUILD_NUMBER}-${env.GIT_SHORT_HASH}"
env.IMAGE_FULL_URL = "${env.DOCKER_REPO}:${env.BUILD_VERSION}"
currentBuild.displayName = "🐳 [${env.TARGET_ENV.toUpperCase()}] ${env.BUILD_VERSION}"
}
// Thực thi lệnh cài đặt và chạy Unit Test theo tham số của từng dự án Monolith
sh """
mkdir -p reports
${cfg.installCommand ?: 'composer install --no-interaction --prefer-dist --no-progress'}
${cfg.testCommand ?: 'XDEBUG_MODE=coverage ./vendor/bin/phpunit --log-junit reports/junit.xml --coverage-clover reports/coverage-clover.xml'}
"""
script {
def scannerHome = tool 'SonarScanner-CLI'
withSonarQubeEnv('SonarQube-Main-Server') {
sh "${scannerHome}/bin/sonar-scanner -Dsonar.projectKey=${env.APP_NAME} -Dsonar.projectVersion=${env.BUILD_VERSION}"
}
}
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
// Gọi trực tiếp hàm buildAndPushDocker nằm cùng trong Shared Library!
buildAndPushDocker(
imageUrl: env.IMAGE_FULL_URL,
version: env.BUILD_VERSION,
commit: env.GIT_SHORT_HASH,
registry: env.DOCKER_REGISTRY,
cacheDir: env.CACHE_ROOT
)
}
post {
always {
junit testResults: 'reports/junit.xml', allowEmptyResults: true
cleanWs(deleteDirs: true, disableDeferredWipeout: true, notFailBuild: true)
}
}
}
stage('2. Manual Approval Gate (Production)') {
agent none
when { expression { env.TARGET_ENV == 'production' } }
steps {
script {
timeout(time: 30, unit: 'MINUTES') {
env.APPROVED_BY = input(
message: "🚨 PHÊ DUYỆT TRIỂN KHAI [${env.BUILD_VERSION}] LÊN PRODUCTION?",
ok: "Phê duyệt & Deploy",
submitter: "${env.PROD_APPROVERS}",
submitterParameter: 'APPROVER_USER'
)
}
}
}
}
stage('3. Zero-Downtime Blue/Green Deploy') {
agent { label "${cfg.agentLabel ?: 'monolith-worker'}" }
steps {
withCredentials([file(credentialsId: "${env.ENV_CRED_ID}", variable: 'SECRET_ENV_FILE')]) {
sh 'cp $SECRET_ENV_FILE .env.target'
sshPublisher(
failOnError: true,
publishers: [
sshPublisherDesc(
configName: "${env.SSH_SERVER_NAME}",
verbose: true,
transfers: [
sshTransfer(
sourceFiles: '.env.target',
remoteDirectory: 'shared',
execCommand: 'mv /var/www/monolith-app/shared/.env.target /var/www/monolith-app/shared/.env && chmod 600 /var/www/monolith-app/shared/.env'
),
sshTransfer(
sourceFiles: '',
execTimeout: 300000,
execCommand: "/var/www/monolith-app/deploy-blue-green.sh ${env.IMAGE_FULL_URL} true"
)
]
)
]
)
}
}
post {
always { sh 'rm -f .env.target' }
}
}
}
post {
success {
node("${cfg.agentLabel ?: 'monolith-worker'}") {
notifyTelegram(status: 'SUCCESS', appName: env.APP_NAME, version: env.BUILD_VERSION, branch: env.CLEAN_BRANCH)
}
}
failure {
node("${cfg.agentLabel ?: 'monolith-worker'}") {
notifyTelegram(status: 'FAILURE', appName: env.APP_NAME, version: env.BUILD_VERSION, branch: env.CLEAN_BRANCH)
}
}
}
}
}
4. Đăng ký Shared Library vào Jenkins Master (Qua UI hoặc JCasC)
Để Jenkins biết nơi tải kho chứa jenkins-monolith-shared-lib.git, bạn khai báo một lần duy nhất trên Jenkins Master:
Cách 1: Cấu hình qua Giao diện Web
-
Truy cập Manage Jenkins -> System, cuộn xuống mục Global Pipeline Libraries (hoặc Global Trusted Pipeline Libraries).
-
Bấm Add và điền các thông số:
-
Name:
monolith-shared-lib(Tên định danh sẽ gọi trong@Libraryở đầuJenkinsfile). -
Default version:
main(hoặc một Git Tag cố định nhưv1.0.0). -
Allow default version to be overridden: Tích chọn (Cho phép các nhánh dev thử nghiệm phiên bản thư viện mới như
@Library('monolith-shared-lib@feature-test')). -
Retrieval method: Chọn Modern SCM -> Git -> Điền URL kho Git của Shared Library và chọn SSH Key (
jenkins-git-ssh-key).
-
-
Bấm Save.
Cách 2: Khai báo tự động trong tệp jenkins.yaml (JCasC - Bài 34)
Nếu bạn đã dùng JCasC ở Bài 34, chỉ cần thêm đoạn sau vào mục unclassified: của tệp jenkins.yaml:
YAML
unclassified:
globalLibraries:
libraries:
- name: "monolith-shared-lib"
defaultVersion: "main"
allowVersionOverride: true
retriever:
modernScm:
scm:
git:
remote: "git@gitlab.yourcompany.com:devops/jenkins-monolith-shared-lib.git"
credentialsId: "jenkins-git-ssh-key"
5. Thành quả ngoạn mục: Tệp Jenkinsfile trong dự án Monolith rút gọn còn 12 dòng!
Sau khi đã có Shared Library, hãy mở kho chứa mã nguồn của dự án Monolith (monolith-core-system), xóa bỏ toàn bộ 200 dòng code phức tạp trước đây và thay thế bằng đúng 12 dòng khai báo sạch đẹp như sau:
Groovy
// Nạp thư viện dùng chung từ nhánh/tag ổn định (Dấu gạch dưới _ ở cuối giúp tự động import mọi hàm trong vars/)
@Library('monolith-shared-lib@main') _
// Gọi khung Pipeline chuẩn và chỉ truyền vào các thông số định danh của dự án Monolith này!
monolithStandardPipeline(
appName : 'monolith-core-system',
projectGroup : 'ecommerce',
agentLabel : 'monolith-worker',
stagingSshServer : 'Staging-Monolith-Server',
stagingEnvCredId : 'monolith-env-staging',
prodSshServer : 'Prod-Monolith-Server',
prodEnvCredId : 'monolith-env-prod',
prodApprovers : 'admin,hieu_techlead'
)
Nếu ngày mai công ty của bạn khởi động thêm dự án thứ hai là Monolith ERP viết bằng Golang, bạn cũng chỉ cần tạo một tệp Jenkinsfile 12 dòng tương tự và truyền thêm 2 dòng ghi đè lệnh test của Go:
Groovy
@Library('monolith-shared-lib@main') _
monolithStandardPipeline(
appName : 'monolith-erp-golang',
installCommand : 'go mod download',
testCommand : 'go test -v -race ./... 2>&1 | go-junit-report -set-exit-code > reports/junit.xml',
stagingSshServer : 'Staging-ERP-Server',
stagingEnvCredId : 'erp-env-staging',
prodSshServer : 'Prod-ERP-Server',
prodEnvCredId : 'erp-env-prod',
prodApprovers : 'admin,hieu_techlead'
)
6. Ba kinh nghiệm "Xương máu" khi vận hành Shared Libraries trong thực tế
-
Quản lý phiên bản thư viện bằng Git Tag (
@v1.0.0) thay vì nhánh@main: Nếu 10 dự án Monolith trong công ty đều gọi@Library('monolith-shared-lib@main'), chỉ cần một kỹ sư DevOps đẩy một dòng code bị lỗi cú pháp Groovy lên nhánhmaincủa Shared Library, cả 10 dự án trong công ty sẽ đồng loạt bị gãy Pipeline ngay lập tức!- Giải pháp chuẩn: Luôn đánh Git Tag cho Shared Library (
v1.0.0,v1.1.0). Các dự án Production sẽ gọi@Library('monolith-shared-lib@v1.0.0'). Khi sửa thư viện, hãy test trên nhánh riêng trước rồi mới nâng Tag mới.
- Giải pháp chuẩn: Luôn đánh Git Tag cho Shared Library (
-
Sử dụng
libraryResourceđể tránh phải copy file script lên từng Server: Thay vì phải SSH vào từng máy chủ mới để tạo tay tệp/var/www/monolith-app/deploy-blue-green.sh(Bài 30), bạn có thể cất tệpdeploy-blue-green.shvào thư mụcresources/scripts/deploy-blue-green.shbên trong Shared Library. Khi chạy bước Deploy, hàm thư viện tự động đọc tệp này bằng lệnhwriteFile file: 'deploy-blue-green.sh', text: libraryResource('scripts/deploy-blue-green.sh')và đẩy kèm lên máy chủ đích! -
Tránh sử dụng các hàm Groovy chặn luồng (
NonCPS) sai cách: Bên dưới Jenkins Pipeline là công cụ Continuation Passing Style (CPS) giúp lưu trạng thái Pipeline xuống đĩa cứng mỗi khi chạy qua một bước (nhưinputở Bài 29). Khi viết code trong thư mụcsrc/hoặcvars/, hạn chế dùng các hàm vòng lặp native phức tạp như.each { ... }kết hợp gọi lệnhshbên trong, mà hãy ưu tiên dùng vòng lặp chuẩnfor (int i = 0; i < list.size(); i++)để tránh lỗi kinh điểnjava.io.NotSerializableException.
All rights reserved