Hướng dẫn Setup Jenkins cho hệ thống Monolith - Bài 29: Quản lý Manual Approval (Yêu cầu phê duyệt thủ công) trước khi đẩy lên Production
Ở Bài 28, chúng ta đã thiết lập hệ thống phân quyền RBAC và làm quen sơ bộ với tham số submitter trong hàm input. Tuy nhiên, trong thực tế vận hành một hệ thống Monolith doanh nghiệp, việc đặt một lệnh input đơn giản giữa Pipeline sẽ ngay lập tức gây ra một sự cố hạ tầng nghiêm trọng: "Tắc nghẽn toàn bộ luồng Build (Executor Starvation)".
Chuyện gì sẽ xảy ra khi Jenkins của bạn chỉ có 2 luồng xử lý (2 Executors), và có 2 bản build đang dừng lại ở bước input chờ Tech Lead đi họp về bấm nút? Toàn bộ máy chủ Jenkins sẽ bị khóa cứng! Các lập trình viên khác đẩy code lên nhánh develop để chạy Unit Test cũng sẽ bị xếp hàng chờ (Pending) vô thời hạn vì các Agent đang bị "ngâm" ở bước chờ phê duyệt!
Bài học hôm nay sẽ hướng dẫn bạn cách thiết kế Cổng phê duyệt thủ công (Manual Approval Gate) chuẩn kiến trúc Production: Không chiếm dụng tài nguyên Agent trong lúc chờ đợi, tự động gửi thông báo nhắc nhở kèm link bấm duyệt qua Telegram, và bắt buộc ghi nhận lý do phát hành (Release Audit Log).
1. Giải phẫu lỗi "Chiếm dụng Executor" (agent any) và cách khắc phục triệt để
Trong Declarative Pipeline của Jenkins, nếu bạn khai báo agent any ở cấp cao nhất (đầu tệp Jenkinsfile), điều đó có nghĩa là: Từ giây đầu tiên Pipeline bắt đầu chạy cho đến khi kết thúc hoàn toàn, nó sẽ giữ chặt (lock) 1 khe thực thi (Executor Slot) và khóa luôn thư mục Workspace trên Agent đó.
Để giải phóng Agent trong lúc chờ con người bấm nút phê duyệt (có thể kéo dài từ 15 phút đến vài tiếng đồng hồ), nguyên tắc vàng là: Stage chứa input phải được chạy với cấu hình agent none!
| Cách cấu hình | Chuyện gì diễn ra bên dưới hệ thống Jenkins? | Hậu quả thực tế |
|---|---|---|
Cách sai: Đặt input bên trong steps { script { input ... } } khi đang giữ agent any. |
Tiến trình chờ chạy trực tiếp trên Worker Agent. Agent bị treo ở trạng thái Busy suốt thời gian chờ bấm nút. |
Tắc nghẽn hàng đợi CI/CD của toàn công ty nếu Tech Lead chưa kịp bấm duyệt. |
Cách chuẩn Production: Sử dụng khối chỉ thị input { ... } gốc của Declarative Stage hoặc tách Stage với agent none. |
Jenkins tạm thời nhả (release) Worker Agent ra cho các Job khác dùng. Trạng thái chờ chỉ tốn vài KB bộ nhớ trên Jenkins Controller. Khi Tech Lead bấm Approve, Jenkins mới cấp phát lại Agent để chạy tiếp bước Deploy! | Hàng chục Pipeline ở nhánh develop vẫn build và chạy test vèo vèo trong lúc bản Production đang nằm chờ duyệt. |
2. Thiết kế Form Phê Duyệt nâng cao: Bắt buộc nhập Ghi chú và Chọn chiến lược Migration
Một cổng phê duyệt Production thực chiến không chỉ có mỗi nút OK và Abort. Khi Tech Lead hoặc Release Manager bấm duyệt, chúng ta cần thu thập thêm các thông số vận hành quan trọng thông qua khối parameters bên trong input:
-
RELEASE_NOTE/CHANGE_TICKET: Mã ticket Jira/Redmine hoặc lý do phát hành (phục vụ kiểm toán sau này). -
RUN_DB_MIGRATION(Boolean): Có cho phép chạy tự động lệnhmigrate --forcetrong lần deploy này hay không. -
CLEAR_SYSTEM_CACHE(Boolean): Có xóa toàn bộ Redis Cache sau khi lên code mới hay không.
Khi kết hợp với submitterParameter, hàm input trong Groovy sẽ trả về một đối tượng Map (Dictionary) chứa đầy đủ cả tên người bấm lẫn tất cả các giá trị mà người đó vừa điền vào Form!
Groovy
// Minh họa cấu trúc dữ liệu trả về khi dùng input có nhiều hơn 1 tham số
def approvalMap = input(
id: 'ProdReleaseGate',
message: "🚀 PHÊ DUYỆT TRIỂN KHAI PHIÊN BẢN [${env.BUILD_VERSION}] LÊN PRODUCTION?",
ok: "Xác nhận & Deploy",
submitter: "admin,hieu_techlead",
submitterParameter: 'APPROVER_USERNAME',
parameters: [
string(name: 'RELEASE_TICKET', defaultValue: 'PROD-2026', description: 'Nhập mã Ticket hoặc ghi chú phát hành (Bắt buộc):'),
booleanParam(name: 'RUN_MIGRATION', defaultValue: true, description: 'Tự động chạy Database Migration khi deploy?'),
choice(name: 'TRAFFIC_MODE', choices: ['NORMAL', 'MAINTENANCE_PAGE'], description: 'Chế độ mở cổng truy cập sau khi deploy:')
]
)
// Cách trích xuất dữ liệu từ Map trả về:
echo "Người phê duyệt: ${approvalMap['APPROVER_USERNAME']}"
echo "Mã Ticket: ${approvalMap['RELEASE_TICKET']}"
echo "Chạy Migration: ${approvalMap['RUN_MIGRATION']}"
3. Quy trình "ChatOps Approval": Gửi thông báo giục duyệt kèm Link trực tiếp
Nếu Pipeline âm thầm chuyển sang trạng thái Paused for Input mà không báo cho ai biết, Tech Lead sẽ không hề hay biết có bản build đang chờ mình.
Quy trình chuẩn của một Stage Approval gồm 3 nhịp:
-
Trước khi dừng (
Pre-Input Notification): Ngay khi vượt qua bước Push Docker Image lên Registry, Jenkins bắn ngay một tin nhắn Telegram/Discord gắn thẻ@TechLead: "🔔 Bản buildv1.2.0-b45đã vượt qua toàn bộ bài test và sẵn sàng lên Production! Bấm vào link này để phê duyệt trong vòng 30 phút:<BUILD_URL>input". -
Đếm ngược thời gian chờ (
Timeout Guard): Đặttimeout(time: 30, unit: 'MINUTES'). Nếu quá 30 phút không ai duyệt, Pipeline tự động hủy (ABORTED) để tránh tình trạng một bản build bị bỏ quên từ sáng đến tối rồi vô tình có người bấm nhầm làm đè code mới. -
Sau khi bấm duyệt (
Post-Input Audit Notification): Gửi thông báo xác nhận vào nhóm chung: "✍️ Tech Leadhieu_techleadvừa phê duyệt đẩy bảnv1.2.0-b45lên Production (Ticket: PROD-105). Đang tiến hành triển khai..."
4. Hoàn thiện Jenkinsfile với kiến trúc Manual Approval không chiếm dụng Agent
Để áp dụng kỹ thuật Giải phóng Agent (agent none) khi chờ phê duyệt mà không bị mất dữ liệu между các Stage, chúng ta sẽ cấu hình agent none ở cấp toàn cục, và chỉ cấp phát agent any ở các Stage thực thi công việc.
Dưới đây là tệp Jenkinsfile hoàn chỉnh chuẩn Production cho Bài 29:
Groovy
pipeline {
// QUAN TRỌNG: Đặt agent none ở cấp cao nhất để Stage Approval không giữ chặt Worker Agent lúc chờ bấm nút!
agent none
options {
buildDiscarder(logRotator(numToKeepStr: '30'))
// Timeout tổng của toàn bộ Pipeline (bao gồm cả thời gian chờ duyệt) là 60 phút
timeout(time: 60, unit: 'MINUTES')
timestamps()
disableConcurrentBuilds()
}
environment {
APP_NAME = "monolith-core-system"
DOCKER_REGISTRY = "registry.yourcompany.com"
DOCKER_REPO = "${DOCKER_REGISTRY}/monolith/${APP_NAME}"
REGISTRY_CRED = "docker-registry-credentials"
DOCKER_BUILDKIT = "1"
// Danh sách các tài khoản có quyền bấm nút Approve lên Production (cách nhau bởi dấu phẩy)
PROD_APPROVERS = "admin,hieu_techlead"
CACHE_ROOT = "/var/jenkins_home/.cache"
COMPOSER_CACHE_DIR = "${CACHE_ROOT}/composer"
SONAR_USER_HOME = "${CACHE_ROOT}/sonar"
}
stages {
// =====================================================================
// GIAI ĐOẠN CI & ĐÓNG GÓI DOCKER IMAGE (Cấp phát Agent để chạy liên tục)
// =====================================================================
stage('1. CI: Test, Scan & Push Image') {
agent any
steps {
checkout([
$class: 'GitSCM',
branches: scm.branches,
extensions: [
[$class: 'CloneOption', depth: 50, noTags: false, shallow: true, timeout: 10]
],
userRemoteConfigs: scm.userRemoteConfigs
])
script {
sh "mkdir -p ${COMPOSER_CACHE_DIR} ${SONAR_USER_HOME}"
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.-]', '-')
env.GIT_AUTHOR = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true).trim()
if (env.CLEAN_BRANCH in ['main', 'master']) {
env.TARGET_ENV = 'production'
env.SSH_SERVER_NAME = 'Prod-Monolith-Server'
env.ENV_CRED_ID = 'monolith-env-prod'
} else {
env.TARGET_ENV = 'staging'
env.SSH_SERVER_NAME = 'Staging-Monolith-Server'
env.ENV_CRED_ID = 'monolith-env-staging'
}
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}"
}
echo "=== 1.1 Chạy Unit Test & Kiểm tra SonarQube ==="
sh '''
composer install --no-interaction --prefer-dist
mkdir -p reports
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.projectVersion=${env.BUILD_VERSION}"
}
}
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
echo "=== 1.2 Build, Trivy Scan & Push Docker Image lên Registry ==="
sh """
docker build --pull --build-arg BUILD_VERSION=${env.BUILD_VERSION} --build-arg GIT_COMMIT=${env.GIT_SHORT_HASH} -t ${env.IMAGE_FULL_URL} -f Dockerfile .
trivy image --cache-dir ${CACHE_ROOT}/trivy --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 ${env.IMAGE_FULL_URL}
"""
withCredentials([usernamePassword(credentialsId: "${env.REGISTRY_CRED}", usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')]) {
sh '''
echo "$REG_PASS" | docker login ${DOCKER_REGISTRY} -u "$REG_USER" --password-stdin
docker push ${IMAGE_FULL_URL}
docker logout ${DOCKER_REGISTRY}
'''
}
// Nếu là nhánh Production, gửi thông báo Telegram kêu gọi Tech Lead vào bấm nút duyệt!
script {
if (env.TARGET_ENV == 'production') {
sendApprovalRequestNotification()
}
}
}
post {
always {
junit testResults: 'reports/junit.xml', allowEmptyResults: false
sh '''
rm -rf reports/ .env*
docker rmi ${IMAGE_FULL_URL} 2>/dev/null || true
'''
}
}
}
// =====================================================================
// STAGE 2 (BÀI 29): CỔNG PHÊ DUYỆT THỦ CÔNG (CHẠY VỚI AGENT NONE!)
// Không chiếm dụng bất kỳ Executor nào của máy chủ trong suốt lúc chờ đợi
// =====================================================================
stage('2. Manual Approval Gate (Production Only)') {
agent none
when {
expression { env.TARGET_ENV == 'production' }
}
steps {
script {
echo "⏸️ Pipeline đang tạm dừng (Không giữ Agent) để chờ phê duyệt từ: [${env.PROD_APPROVERS}]"
// Giới hạn thời gian chờ phê duyệt tối đa 30 phút
timeout(time: 30, unit: 'MINUTES') {
def approvalResult = input(
id: 'ProductionApprovalGate',
message: "🚨 PHÊ DUYỆT PHÁT HÀNH [${env.BUILD_VERSION}] LÊN PRODUCTION?",
ok: "Phê duyệt & Triển khai",
submitter: "${env.PROD_APPROVERS}",
submitterParameter: 'APPROVER_USER',
parameters: [
string(
name: 'RELEASE_NOTE',
defaultValue: "Release ${env.BUILD_VERSION}",
description: 'Nhập mã Ticket (Jira/Redmine) hoặc lý do phát hành bản này:'
),
booleanParam(
name: 'RUN_DB_MIGRATION',
defaultValue: true,
description: 'Tích chọn nếu cho phép chạy php artisan migrate --force sau khi bật container.'
)
]
)
// Kiểm tra bắt buộc không được để trống ghi chú phát hành
if (!approvalResult['RELEASE_NOTE']?.trim()) {
error "❌ TỪ CHỐI TRIỂN KHAI: Bạn bắt buộc phải nhập Mã Ticket hoặc Ghi chú phát hành (RELEASE_NOTE)!"
}
// Lưu thông tin phê duyệt vào biến môi trường toàn cục để Stage 3 sử dụng
env.APPROVED_BY = approvalResult['APPROVER_USER']
env.RELEASE_NOTE = approvalResult['RELEASE_NOTE'].trim()
env.RUN_DB_MIGRATION = approvalResult['RUN_DB_MIGRATION'].toString()
// Cập nhật ngay lên giao diện lịch sử Build của Jenkins
currentBuild.description = "✅ Approved by: ${env.APPROVED_BY} | Note: ${env.RELEASE_NOTE}"
echo "✅ Đã phê duyệt bởi [${env.APPROVED_BY}] với ghi chú: [${env.RELEASE_NOTE}]"
}
}
}
}
// =====================================================================
// STAGE 3: CẤP PHÁT LẠI AGENT ĐỂ THỰC THI LỆNH DEPLOY LÊN SERVER ĐÍCH
// =====================================================================
stage('3. Execute Deployment') {
agent any
steps {
script {
echo "=== Bắt đầu triển khai phiên bản ${env.BUILD_VERSION} lên [${env.SSH_SERVER_NAME}] ==="
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: '',
remoteDirectory: '',
execTimeout: 300000,
// Truyền thêm cờ RUN_DB_MIGRATION lấy từ form phê duyệt xuống cho script server
execCommand: """
/var/www/monolith-app/deploy-docker.sh ${env.IMAGE_FULL_URL}
if [ "${env.RUN_DB_MIGRATION}" = "true" ]; then
echo "🔄 Đang chạy Database Migration theo lệnh phê duyệt..."
docker exec monolith-core-app php artisan migrate --force
else
echo "⏭️ Bỏ qua bước Database Migration theo chỉ định của người phê duyệt."
fi
"""
)
]
)
]
)
}
// Gửi thông báo hoàn tất phát hành kèm tên người phê duyệt
sendDeployResultNotification('SUCCESS')
}
}
post {
always {
sh 'rm -f .env.target'
}
}
}
}
}
// Hàm 1: Gửi tin nhắn Telegram kêu gọi Tech Lead bấm vào link phê duyệt
def sendApprovalRequestNotification() {
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="⏳ <b>[CHỜ PHÊ DUYỆT PRODUCTION] ${env.APP_NAME}</b>
━━━━━━━━━━━━━━━
🏷 <b>Version:</b> <code>${env.BUILD_VERSION}</code>
👤 <b>Dev Commit:</b> ${env.GIT_AUTHOR}
👮♂️ <b>Người có quyền duyệt:</b> <code>${env.PROD_APPROVERS}</code>
⏰ <b>Thời hạn chờ:</b> 30 phút
━━━━━━━━━━━━━━━
👉 <a href='${env.BUILD_URL}input'>BẤM VÀO ĐÂY ĐỂ PHÊ DUYỆT HOẶC TỪ CHỐI</a>" > /dev/null || true
"""
}
}
// Hàm 2: Gửi tin nhắn xác nhận sau khi đã phê duyệt và triển khai xong
def sendDeployResultNotification(String status) {
def approverInfo = env.APPROVED_BY ? "${env.APPROVED_BY} (Note: ${env.RELEASE_NOTE})" : "Auto-Deploy (Staging)"
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" \\
--data-urlencode text="🚀 <b>[DEPLOY ${status}] ${env.APP_NAME} (${env.TARGET_ENV.toUpperCase()})</b>
━━━━━━━━━━━━━━━
🏷 <b>Version:</b> <code>${env.BUILD_VERSION}</code>
✍️ <b>Phê duyệt bởi:</b> ${approverInfo}
🗄 <b>DB Migration:</b> <code>${env.RUN_DB_MIGRATION ?: 'true'}</code>" > /dev/null || true
"""
}
}
5. Xử lý kịch bản जब Tech Lead bấm nút "Abort" (Từ chối phê duyệt)
Khi Tech Lead mở đường link <BUILD_URL>input và nhận thấy thời điểm hiện tại đang là khung giờ cao điểm Flash Sale nên quyết định bấm nút Abort (Hủy bỏ):
-
Tiến trình
inputsẽ ném ra một ngoại lệorg.jenkinsci.plugins.workflow.steps.FlowInterruptedException. -
Jenkins tự động đánh dấu trạng thái của bản build là
ABORTED(Màu xám) chứ không phảiFAILURE(Màu đỏ). Điều này cực kỳ hợp lý vì mã nguồn không hề có lỗi kỹ thuật, chỉ là quyết định quản trị tạm hoãn phát hành. -
Bạn có thể bắt riêng trạng thái này bằng khối
post { aborted { ... } }để gửi tin nhắn thông báo cho đội ngũ biết bản phát hành đã bị hủy chủ động bởi ai.
All Rights Reserved