0

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ặc application-prod.yml) vào bên trong file nén monolith-v1.x.x.tar.gz ở bước Build!

  • Nếu bạn nhét .env củ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 .env củ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.gz là 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/.env nằ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, script deploy.sh chỉ 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/.env của máy chủ đích trước khi chạy deploy.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ệnh envsubst củ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:

  1. 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-payment và chọn nhầm dropdown production, Jenkins sẽ lập tức hủy bỏ bản build ngay từ giây đầu tiên!

  2. Phân quyền đọc file .env chuẩn chmod 600: Ngay sau khi đẩy tệp .env lên thư mục /var/www/monolith-app/shared/.env trên máy chủ đích, lệnh chmod 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.

  3. 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 symlink current, bạn có thể bổ sung một dòng kiểm tra nhanh xem tệp .env vừ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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí