Hướng dẫn Setup Jenkins cho hệ thống Monolith - Bài 24: Quản lý cấu hình riêng biệt cho từng môi trường (Dev, Staging, Production) với Jenkins
Ở Bài 22 và 23, chúng ta đã dựng thành công quy trình tự động đẩy gói Artifact lên máy chủ Staging và kích hoạt script deploy.sh. Tuy nhiên, trong thực tế vận hành một hệ thống Monolith lớn, bạn không chỉ có một máy chủ duy nhất. Một dự án chuẩn chỉnh luôn đi qua ít nhất 3 môi trường: Development (Dev) -> Staging (UAT/Pre-prod) -> Production (Prod).
Mỗi môi trường này có địa chỉ IP máy chủ khác nhau, kết nối tới các cụm Database/Redis khác nhau, sử dụng tài khoản thanh toán (Sandbox vs Live) khác nhau và mức độ bật/tắt Debug hoàn toàn trái ngược. Nếu bạn copy-paste ra 3 tệp Jenkinsfile.dev, Jenkinsfile.staging, Jenkinsfile.prod hoặc sửa tay tệp .env mỗi lần deploy, sớm muộn gì thảm họa "Môi trường Staging gọi nhầm API xóa sạch dữ liệu của Production" cũng sẽ xảy ra.
Bài học hôm nay sẽ hướng dẫn bạn cách thiết kế kiến trúc Quản lý Đa Môi Trường (Multi-Environment Configuration) trong một tệp Jenkinsfile duy nhất, tuân thủ nghiêm ngặt tiêu chuẩn 12-Factor App.
1. Nguyên tắc sống còn: Tách biệt tuyệt đối Code (Artifact) và Cấu hình (Config)
Theo nguyên tắc thứ 3 của The Twelve-Factor App (Bản tuyên ngôn thiết kế phần mềm hiện đại): Cấu hình môi trường phải được tách rời hoàn toàn khỏi mã nguồn.
Khi áp dụng vào hệ thống Monolith trên Jenkins, điều này có nghĩa là:
-
Tuyệt đối không đóng gói tệp
.env(hoặcapplication-prod.yml) vào bên trong file nénmonolith-v1.x.x.tar.gzở bước Build! -
Nếu bạn nhét
.envcủa Staging vào trong gói.tar.gz, thì gói.tar.gzđó chỉ chạy được ở Staging. Khi muốn lên Production, bạn lại phải chạy lại lệnh Build từ đầu để nhét.envcủa Production vào — vi phạm nguyên tắc "Build Once, Deploy Anywhere" đã học ở Bài 20. -
Cách làm chuẩn: Gói Artifact
.tar.gzlà hoàn toàn trung lập (Environment-Agnostic). Chỉ đến bước Deploy (Stage 6, 7), Jenkins nhảy vào môi trường nào thì mới lấy bộ cấu hình tương ứng của môi trường đó bơm (inject) vào máy chủ đích!
2. Bản đồ phân tách cấu hình giữa Dev, Staging và Production
Trước khi viết code cho Jenkins, một kỹ sư hệ thống cần lập bảng quy hoạch rõ ràng sự khác biệt giữa 3 môi trường của khối Monolith:
| Thành phần cấu hình | Môi trường DEV (develop) |
Môi trường STAGING (release/* / staging) |
Môi trường PRODUCTION (main / master) |
|---|---|---|---|
| Mục đích sử dụng | Lập trình viên tích hợp code và test nhanh tính năng mới hàng giờ. | Đội ngũ QA/QC kiểm thử nghiệm thu (UAT), chạy tải thử nghiệm (Load Test). |
Phục vụ khách hàng thực tế, yêu cầu độ ổn định cao tuyệt đối (99.99%). |
Cờ Debug (APP_DEBUG) |
true (Hiển thị chi tiết Stack Trace lỗi trực tiếp trên màn hình). |
false (Lưu chi tiết vào log hệ thống, ẩn thông tin lỗi với người dùng). |
false (Bắt buộc tắt tuyệt đối để tránh lộ cấu trúc thư viện và hệ thống). |
| Database & Redis | Database nội bộ dùng chung, dữ liệu giả lập (Seed / Faker). |
Database có cấu trúc giống hệt Production, dữ liệu đã được ẩn danh (Anonymized). |
Cụm Database Master-Replica thực tế, cấu hình bảo mật nhiều lớp khắt khe. |
| Bên thứ 3 (Payment, SMS, Mail) | Mock Server hoặc Mailtrap (Tuyệt đối không gửi tin nhắn/email thật ra ngoài). | Cổng Sandbox của đối tác tích hợp (VNPay Sandbox, ZaloPay Sandbox). | Cổng Live chính thức (Trừ tiền thật tài khoản ngân hàng, gửi SMS/Email thật). |
| SSH Server Config (Jenkins) | Dev-Monolith-Server |
Staging-Monolith-Server |
Prod-Monolith-Server |
| Jenkins Credentials ID | monolith-env-dev |
monolith-env-staging |
monolith-env-prod |
3. Ba phương pháp quản lý tệp cấu hình (.env / .yml) trên Jenkins
Để quản lý các tệp cấu hình riêng biệt cho từng môi trường, chúng ta có 3 phương pháp phổ biến từ cơ bản đến nâng cao:
Phương pháp 1: Quản lý trực tiếp trên Server đích (shared/.env)
-
Cách hoạt động: Như chúng ta đã thiết lập ở Bài 23, tệp
/var/www/monolith-app/shared/.envnằm cố định trên từng máy chủ Staging và Production (chỉ có DevOps/SysAdmin mới có quyền SSH vào sửa). Khi deploy, scriptdeploy.shchỉ việc nối symlink:ln -sfn /var/www/monolith-app/shared/.env /var/www/monolith-app/releases/${VERSION}/.env. -
Ưu điểm: Bảo mật cực cao (Jenkins thậm chí không cần giữ mật khẩu Production DB).
-
Nhược điểm: Khi lập trình viên thêm một biến môi trường mới (ví dụ
REDIS_CLUSTER_ENABLED=true), DevOps phải tự nhớ SSH vào cả 3 server để thêm dòng đó vào tay, dễ bị quên gây lỗi thiếu biến.
Phương pháp 2: Quản lý tập trung bằng Jenkins Secret Files (Khuyên dùng)
-
Cách hoạt động: Bạn tạo 3 tệp cấu hình trên máy tính (
env.dev,env.staging,env.prod), sau đó tải cả 3 tệp này lên kho lưu trữ mã hóa Jenkins Credentials (chọn loại Secret file) với 3 mã ID chuẩn hóa:-
monolith-env-dev -
monolith-env-staging -
monolith-env-prod
-
-
Khi Jenkins deploy lên môi trường nào, nó sẽ tự động trích xuất đúng tệp Secret File của môi trường đó và đẩy đè lên thư mục
shared/.envcủa máy chủ đích trước khi chạydeploy.sh. -
Ưu điểm: Quản lý tập trung 100% trên giao diện Jenkins, không bao giờ sợ quên cập nhật biến mới, dễ dàng phân quyền theo Folder/Role.
Phương pháp 3: Sử dụng Config File Provider Plugin (Dành cho Template động)
- Nếu bạn dùng Java Spring Boot (
application.yml), Nginx config hoặc các file JSON có cấu trúc giống nhau đến 90% và chỉ khác vài biến mật khẩu: Bạn cài plugin Config File Provider, tạo một file mẫu chứa các biến${DB_HOST},${DB_PASS}, sau đó dùng lệnhenvsubstcủa Linux để điền giá trị thực tế vào lúc deploy.
4. Kỹ thuật thiết kế "Dynamic Environment Mapping" trong Jenkinsfile
Làm sao để chỉ với 1 tệp Jenkinsfile duy nhất, khi dev đẩy code vào nhánh develop thì Jenkins tự động lấy cấu hình của Dev, đẩy vào nhánh staging thì lấy cấu hình của Staging, còn khi merge vào main thì lấy cấu hình của Production?
Chúng ta sẽ sử dụng kỹ thuật Ánh xạ cấu hình động theo nhánh Git (Branch-to-Environment Mapping) ngay ở Stage đầu tiên của Pipeline:
Groovy
script {
// Khởi tạo bảng ánh xạ (Map) cấu hình cho từng môi trường dựa trên tên nhánh Git
def envConfigMap = [
'develop': [
targetEnv : 'development',
sshServerName : 'Dev-Monolith-Server',
envCredId : 'monolith-env-dev',
appUrl : 'https://dev.yourcompany.com'
],
'staging': [
targetEnv : 'staging',
sshServerName : 'Staging-Monolith-Server',
envCredId : 'monolith-env-staging',
appUrl : 'https://staging.yourcompany.com'
],
'main': [
targetEnv : 'production',
sshServerName : 'Prod-Monolith-Server',
envCredId : 'monolith-env-prod',
appUrl : 'https://yourcompany.com'
]
]
// Lấy cấu hình tương ứng với nhánh đang chạy (Nếu là nhánh feature/* thì mặc định chỉ chạy CI, không có targetEnv)
def currentConfig = envConfigMap[env.CLEAN_BRANCH]
if (currentConfig != null) {
env.TARGET_ENV = currentConfig.targetEnv
env.SSH_SERVER_NAME = currentConfig.sshServerName
env.ENV_CRED_ID = currentConfig.envCredId
env.APP_URL = currentConfig.appUrl
echo "🎯 Nhánh [${env.CLEAN_BRANCH}] được ánh xạ tự động tới môi trường: [${env.TARGET_ENV.toUpperCase()}]"
} else {
env.TARGET_ENV = 'none'
echo "ℹ️ Nhánh tính năng [${env.CLEAN_BRANCH}] chỉ thực hiện kiểm thử CI, bỏ qua bước Deploy."
}
}
5. Hoàn thiện Jenkinsfile Đa Môi Trường (Multi-Environment CI/CD)
Dưới đây là tệp Jenkinsfile hoàn chỉnh kết hợp cả Tự động nhận diện môi trường qua nhánh Git và Tùy chọn chọn môi trường thủ công (choice parameter), đồng thời tự động đồng bộ file .env bảo mật từ Jenkins Credentials xuống đúng máy chủ đích trước khi kích hoạt deploy.sh:
Groovy
pipeline {
agent any
// Cho phép Tech Lead chủ động chọn môi trường hoặc chế độ chạy khi bấm "Build with Parameters"
parameters {
choice(
name: 'DEPLOY_ENV_OVERRIDE',
choices: ['AUTO_BY_BRANCH', 'development', 'staging', 'production'],
description: 'Chọn AUTO_BY_BRANCH để tự động xác định môi trường theo nhánh Git, hoặc chọn cụ thể để ép chạy môi trường đích.'
)
}
options {
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '5'))
timeout(time: 30, unit: 'MINUTES')
timestamps()
// Chặn không cho 2 bản build chạy đè lên nhau cùng lúc trên cùng một nhánh
disableConcurrentBuilds()
}
environment {
APP_NAME = "monolith-core-system"
CACHE_ROOT = "/var/jenkins_home/.cache"
COMPOSER_CACHE_DIR = "${CACHE_ROOT}/composer"
NPM_CONFIG_CACHE = "${CACHE_ROOT}/npm"
SONAR_USER_HOME = "${CACHE_ROOT}/sonar"
}
stages {
stage('1. Checkout & Environment Mapping') {
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} ${NPM_CONFIG_CACHE} ${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.-]', '-')
// 1. Định nghĩa cấu hình chuẩn cho 3 môi trường
def envCatalog = [
'development': [
sshServer : 'Dev-Monolith-Server',
credId : 'monolith-env-dev',
url : 'https://dev.yourcompany.com'
],
'staging': [
sshServer : 'Staging-Monolith-Server',
credId : 'monolith-env-staging',
url : 'https://staging.yourcompany.com'
],
'production': [
sshServer : 'Prod-Monolith-Server',
credId : 'monolith-env-prod',
url : 'https://yourcompany.com'
]
]
// 2. Xác định môi trường đích dựa trên Parameter hoặc Nhánh Git
if (params.DEPLOY_ENV_OVERRIDE != 'AUTO_BY_BRANCH') {
env.TARGET_ENV = params.DEPLOY_ENV_OVERRIDE
} else if (env.CLEAN_BRANCH == 'main' || env.CLEAN_BRANCH == 'master') {
env.TARGET_ENV = 'production'
} else if (env.CLEAN_BRANCH == 'staging' || env.CLEAN_BRANCH.startsWith('release-')) {
env.TARGET_ENV = 'staging'
} else if (env.CLEAN_BRANCH == 'develop') {
env.TARGET_ENV = 'development'
} else {
env.TARGET_ENV = 'ci_only'
}
// 3. Gán biến môi trường điều phối nếu có mục tiêu Deploy
if (env.TARGET_ENV != 'ci_only') {
// Chốt chặn an toàn: Không bao giờ cho phép nhánh khác main/master deploy lên Production!
if (env.TARGET_ENV == 'production' && !(env.CLEAN_BRANCH in ['main', 'master'])) {
error "❌ VI PHẠM BẢO MẬT: Không được phép triển khai nhánh [${env.CLEAN_BRANCH}] lên môi trường PRODUCTION!"
}
def selected = envCatalog[env.TARGET_ENV]
env.SSH_SERVER_NAME = selected.sshServer
env.ENV_CRED_ID = selected.credId
env.APP_URL = selected.url
}
// 4. Thiết lập BUILD_VERSION và tên hiển thị trực quan
env.BUILD_VERSION = "${env.LATEST_SEMVER_TAG}-${env.TARGET_ENV}.b${env.BUILD_NUMBER}-${env.GIT_SHORT_HASH}"
env.ARTIFACT_NAME = "${env.APP_NAME}-${env.BUILD_VERSION}.tar.gz"
currentBuild.displayName = "[${env.TARGET_ENV.toUpperCase()}] ${env.LATEST_SEMVER_TAG}-b${env.BUILD_NUMBER}"
currentBuild.description = "Branch: ${env.CLEAN_BRANCH} | Commit: ${env.GIT_SHORT_HASH}"
}
}
}
stage('2. Cached Install Dependencies') {
steps {
sh 'composer install --no-interaction --prefer-dist'
}
}
stage('3. Run Unit Tests & Coverage') {
environment {
APP_ENV = "testing"
DB_CONNECTION = "sqlite"
DB_DATABASE = ":memory:"
XDEBUG_MODE = "coverage"
}
steps {
sh '''
mkdir -p reports
./vendor/bin/phpunit \
--log-junit reports/junit.xml \
--coverage-clover reports/coverage-clover.xml
'''
}
post {
always {
junit testResults: 'reports/junit.xml', allowEmptyResults: false
}
}
}
stage('4. Security & SonarQube Gate') {
steps {
script {
sh "trivy fs --cache-dir ${CACHE_ROOT}/trivy --scanners vuln,secret --severity CRITICAL --exit-code 1 ."
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
}
}
}
stage('5. Build Environment-Agnostic Artifact') {
steps {
echo "=== Đóng gói Artifact trung lập (Không chứa file .env bên trong) ==="
sh '''
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
cat <<EOF > version.json
{
"service": "${APP_NAME}",
"version": "${BUILD_VERSION}",
"commit": "${GIT_SHORT_HASH}",
"branch": "${CLEAN_BRANCH}"
}
EOF
# Đảm bảo loại trừ tuyệt đối mọi file .env* khỏi gói nén Artifact
tar --exclude='.git' \
--exclude='.env*' \
--exclude='node_modules' \
--exclude='tests' \
--exclude='reports' \
--exclude="${ARTIFACT_NAME}*" \
-czf ${ARTIFACT_NAME} .
sha256sum ${ARTIFACT_NAME} > ${ARTIFACT_NAME}.sha256
'''
archiveArtifacts artifacts: "${env.ARTIFACT_NAME}, ${env.ARTIFACT_NAME}.sha256, version.json", fingerprint: true
}
}
// STAGE 6 (BÀI 24): TỰ ĐỘNG NẠP CẤU HÌNH RIÊNG BIỆT VÀ TRIỂN KHAI THEO MÔI TRƯỜNG ĐÍCH
stage('6. Inject Config & Deploy to Target Environment') {
when {
expression { env.TARGET_ENV != 'ci_only' }
}
steps {
script {
// Nếu đích đến là PRODUCTION, bắt buộc phải dừng lại hỏi ý kiến phê duyệt!
if (env.TARGET_ENV == 'production') {
timeout(time: 15, unit: 'MINUTES') {
input message: "⚠️ XÁC NHẬN TRIỂN KHAI LÊN PRODUCTION (${env.APP_URL})?",
ok: "Phê duyệt & Deploy"
}
}
echo "=== Bắt đầu nạp cấu hình [${env.ENV_CRED_ID}] và triển khai lên máy chủ [${env.SSH_SERVER_NAME}] ==="
// Trích xuất đúng file Secret .env của môi trường tương ứng từ Jenkins Credentials Store
withCredentials([file(credentialsId: "${env.ENV_CRED_ID}", variable: 'SECRET_ENV_FILE')]) {
// Copy file secret ra một file tạm để SSH Publisher truyền lên thư mục shared/ của server đích
sh 'cp $SECRET_ENV_FILE .env.target'
sshPublisher(
failOnError: true,
publishers: [
sshPublisherDesc(
configName: "${env.SSH_SERVER_NAME}",
verbose: true,
transfers: [
// Bước truyền 1: Đẩy file cấu hình .env chuẩn vào thư mục shared/ của Server
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
'''
),
// Bước truyền 2: Đẩy gói Artifact và kích hoạt deploy.sh (Bài 23)
sshTransfer(
sourceFiles: "${env.ARTIFACT_NAME}, ${env.ARTIFACT_NAME}.sha256",
remoteDirectory: 'artifacts',
execTimeout: 300000,
execCommand: "/var/www/monolith-app/deploy.sh ${env.BUILD_VERSION}"
)
]
)
]
)
}
}
}
post {
always {
// Xóa ngay lập tức file .env.target tạm trên Jenkins Workspace sau khi truyền xong!
sh 'rm -f .env.target'
}
}
}
}
post {
always {
sh "rm -rf reports/ version.json .env* ${env.ARTIFACT_NAME}*"
}
}
}
6. Ba quy tắc "Bảo hiểm tính mạng" khi quản lý cấu hình Đa Môi Trường
Hãy nhìn kỹ vào đoạn code ở Stage 1 và Stage 6 bên trên, bạn sẽ thấy 3 lớp khóa an toàn cực kỳ đắt giá đã được cài vào:
-
Chốt chặn chống Deploy nhầm nhánh (
Branch Guard):Groovy
if (env.TARGET_ENV == 'production' && !(env.CLEAN_BRANCH in ['main', 'master'])) { error "❌ VI PHẠM BẢO MẬT: Không được phép triển khai nhánh [${env.CLEAN_BRANCH}] lên môi trường PRODUCTION!" }Nhờ dòng lệnh này, dù một lập trình viên có lỡ tay bấm Build with Parameters trên nhánh
feature/test-paymentvà chọn nhầm dropdownproduction, Jenkins sẽ lập tức hủy bỏ bản build ngay từ giây đầu tiên! -
Phân quyền đọc file
.envchuẩnchmod 600: Ngay sau khi đẩy tệp.envlên thư mục/var/www/monolith-app/shared/.envtrên máy chủ đích, lệnhchmod 600đảm bảo chỉ duy nhất user sở hữu (deploy) mới có quyền đọc tệp này, các user khác trên máy chủ hoàn toàn không thể xem trộm mật khẩu Database. -
Kiểm tra biến bắt buộc trước khi tráo Symlink (Env Validation): Trong tệp
deploy.sh(Bài 23), trước khi tráo đổi symlinkcurrent, bạn có thể bổ sung một dòng kiểm tra nhanh xem tệp.envvừa đẩy lên có bị thiếu các khóa cốt lõi hay không:Bash
# Kiểm tra đảm bảo các biến quan trọng bắt buộc phải tồn tại và không rỗng trong file .env for required_var in APP_KEY DB_HOST DB_DATABASE REDIS_HOST; do if ! grep -q "^${required_var}=.\+" "${SHARED_DIR}/.env"; then echo "❌ LỖI CẤU HÌNH: Biến bắt buộc [${required_var}] bị thiếu hoặc đang để trống trong file .env!" exit 1 fi done
All rights reserved