0

Hướng dẫn Setup Jenkins cho hệ thống Monolith - Bài 22: Triển khai ứng dụng Monolith lên Staging Server sử dụng SSH Publisher Plugin

Sau khi đã định hình rõ chiến lược triển khai ở Bài 21, hôm nay chúng ta sẽ bắt tay vào cấu hình thực tế đầu tiên của Giai đoạn 3: Đưa gói Artifact (monolith-core-system-v1.x.x.tar.gz) từ máy chủ Jenkins sang máy chủ Staging Server thông qua giao thức SSH và kích hoạt chạy ứng dụng hoàn toàn tự động.

Trong hệ sinh thái Jenkins, công cụ truyền thống và trực quan nhất để thực hiện việc "vừa đẩy file qua SFTP/SCP, vừa chạy lệnh shell từ xa" chính là plugin Publish Over SSH (thường gọi là SSH Publisher Plugin).

1. Kiến trúc kết nối bảo mật từ Jenkins sang Staging Server

Nguyên tắc bảo mật cốt lõi khi thiết lập kết nối từ Jenkins tới các máy chủ đích (Staging / Production) là: Tuyệt đối không bao giờ dùng tài khoản root của máy chủ đích để cấu hình trên Jenkins!

Nếu dùng tài khoản root, một lập trình viên vô tình viết lệnh rm -rf / trong Jenkinsfile sẽ xóa trắng toàn bộ hệ điều hành của Staging Server. Kiến trúc chuẩn thực chiến phải tuân theo nguyên tắc Least Privilege (Đặc quyền tối thiểu):

  1. Tạo một tài khoản hệ thống riêng biệt trên Staging Server tên là deploy (thuộc nhóm www-data hoặc nhóm chạy ứng dụng).

  2. Tài khoản deploy chỉ có quyền ghi dữ liệu vào đúng thư mục /var/www/monolith-app.

  3. Cấp quyền sudo không cần mật khẩu (NOPASSWD) cho tài khoản deploy chỉ với đúng các câu lệnh khởi động lại dịch vụ cần thiết (ví dụ: systemctl reload php8.2-fpm, systemctl restart monolith-app, hoặc supervisorctl restart all).

2. Chuẩn bị hạ tầng và phân quyền trên máy chủ Staging (Linux)

Hãy SSH vào máy chủ Staging Server của bạn và thực hiện tuần tự 4 bước chuẩn bị sau:

Bước 1: Tạo user deploy và cấu trúc thư mục Atomic Symlink (đã học ở Bài 21)

Bash

# 1. Tạo user deploy và thêm vào group www-data (nếu chạy Nginx/PHP-FPM)
sudo useradd -m -s /bin/bash -G www-data deploy

# 2. Tạo cấu trúc thư mục chuẩn cho dự án Monolith
sudo mkdir -p /var/www/monolith-app/{releases,shared,artifacts}
sudo mkdir -p /var/www/monolith-app/shared/{storage,logs}

# 3. Trao quyền sở hữu toàn bộ thư mục này cho user deploy
sudo chown -R deploy:www-data /var/www/monolith-app
sudo chmod -R 775 /var/www/monolith-app

Bước 2: Cấp quyền sudo giới hạn cho user deploy

Tạo tệp cấu hình sudoers riêng cho user deploy:

Bash

sudo tee /etc/sudoers.d/deploy-monolith << 'EOF'
deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload php8.2-fpm
deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart monolith-service
deploy ALL=(ALL) NOPASSWD: /usr/bin/supervisorctl *
EOF

# Đặt quyền chuẩn 0440 cho file sudoers (bắt buộc)
sudo chmod 0440 /etc/sudoers.d/deploy-monolith

Bước 3: Tạo cặp khóa SSH chuyên dụng cho việc Deploy

Quay trở lại máy chủ Jenkins (hoặc máy cá nhân của bạn), tạo một cặp khóa SSH riêng biệt dành cho việc kết nối tới Staging Server:

Bash

ssh-keygen -t ed25519 -C "jenkins-to-staging-deploy" -f ~/.ssh/jenkins_staging_key -N ""

  • Lệnh này tạo ra 2 file: jenkins_staging_key (Private Key - giữ trên Jenkins) và jenkins_staging_key.pub (Public Key - đưa sang Staging Server).

  • Copy nội dung file jenkins_staging_key.pub và dán vào tệp /home/deploy/.ssh/authorized_keys trên Staging Server:

Bash

# Thực hiện trên Staging Server:
sudo -u deploy mkdir -p /home/deploy/.ssh
sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys <<< "ssh-ed25519 AAAA... nội_dung_public_key_của_bạn"
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys

3. Cài đặt và cấu hình Publish Over SSH Plugin trên Jenkins

Sau khi Staging Server đã sẵn sàng đón nhận kết nối, bạn mở giao diện Web của Jenkins để khai báo máy chủ đích:

Bước 1: Cài đặt Plugin

  1. Vào Manage Jenkins -> Plugins -> Available plugins.

  2. Tìm kiếm từ khóa Publish Over SSH (và cài thêm SSH Pipeline Steps nếu muốn linh hoạt).

  3. Tích chọn và bấm Install.

Bước 2: Khai báo thông tin Staging Server trong System Configuration

  1. Vào Manage Jenkins -> System, cuộn xuống dưới cùng tìm mục Publish over SSH.

  2. Tại phần Key (Hoặc Passphrase / Key): Dán toàn bộ nội dung của tệp Private Key (jenkins_staging_key) vào ô Key.

  3. Tại phần SSH Servers, bấm nút Add:

    • Name: Staging-Monolith-Server (Nhớ chính xác tên này để gọi trong Jenkinsfile).

    • Hostname: Nhập địa chỉ IP của Staging Server (ví dụ: 10.0.1.25 hoặc 103.x.x.x).

    • Username: deploy

    • Remote Directory: /var/www/monolith-app (Đây là thư mục gốc mà mọi thao tác đẩy file của Jenkins sẽ lấy làm mốc).

  4. Bấm vào nút Advanced ngay bên dưới để kiểm tra cổng SSH (mặc định là 22), sau đó bấm nút Test Configuration.

  5. Nếu màn hình hiện chữ Success, nghĩa là Jenkins đã SSH vào Staging Server thành công! Bấm Save để lưu lại.

4. Giải mã cú pháp sshPublisher trong Declarative Pipeline

Khi gọi plugin Publish Over SSH bên trong Jenkinsfile, chúng ta sử dụng hàm sshPublisher. Có 4 tham số cực kỳ quan trọng mà bạn cần nắm vững để không bị lỗi đường dẫn hoặc nuốt mã lỗi:

Groovy

sshPublisher(
    // 1. BẮT BUỘC BẬT TRUE: Nếu script trên Staging bị lỗi (exit code != 0), Jenkins phải báo ĐỎ ngay!
    // Mặc định của plugin này nếu không khai báo failOnError sẽ chỉ báo UNSTABLE (Vàng) dù lệnh deploy chết.
    failOnError: true,
    
    publishers: [
        sshPublisherDesc(
            configName: 'Staging-Monolith-Server', // Tên server đã đặt ở Manage Jenkins -> System
            verbose: true,                         // In toàn bộ log chạy trên Staging ra màn hình Console của Jenkins
            transfers: [
                sshTransfer(
                    // 2. Các file cần gửi từ Workspace của Jenkins sang Staging Server
                    sourceFiles: "${env.ARTIFACT_NAME}, ${env.ARTIFACT_NAME}.sha256",
                    
                    // 3. Thư mục con trên Staging Server (Sẽ nối vào sau Remote Directory: /var/www/monolith-app/artifacts)
                    remoteDirectory: 'artifacts',
                    
                    // 4. Tập lệnh Bash sẽ thực thi trên Staging Server NGAY SAU KHI truyền file xong
                    execCommand: '''
                        echo "Đã nhận gói cài đặt thành công!"
                    ''',
                    execTimeout: 300000 // Timeout 5 phút (300.000 ms)
                )
            ]
        )
    ]
)

5. Hoàn thiện Jenkinsfile tự động Deploy lên Staging Server

Dưới đây là cách chúng ta tích hợp Stage 7: Deploy to Staging vào Jenkinsfile hiện tại. Ngay sau khi gói Artifact được tạo ra, Jenkins sẽ đẩy tệp .tar.gz kèm tệp .sha256 sang thư mục /var/www/monolith-app/artifacts trên Staging Server, kiểm tra tính toàn vẹn, giải nén vào thư mục releases/${BUILD_VERSION}, nối symlink .env, cập nhật mũi tên current và khởi động lại dịch vụ!

Groovy

pipeline {
    agent any

    options {
        buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '5'))
        timeout(time: 30, unit: 'MINUTES')
        timestamps()
    }

    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. Fast Checkout & Versioning') {
            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.-]', '-')

                    if (env.CLEAN_BRANCH == 'main' || env.CLEAN_BRANCH == 'master') {
                        env.BUILD_VERSION = "${env.LATEST_SEMVER_TAG}-b${env.BUILD_NUMBER}-${env.GIT_SHORT_HASH}"
                    } else {
                        env.BUILD_VERSION = "${env.LATEST_SEMVER_TAG}-${env.CLEAN_BRANCH}.${env.BUILD_NUMBER}-${env.GIT_SHORT_HASH}"
                    }

                    env.ARTIFACT_NAME = "${env.APP_NAME}-${env.BUILD_VERSION}.tar.gz"
                    currentBuild.displayName = "${env.BUILD_VERSION}"
                }
            }
        }

        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 & Archive Artifact') {
            steps {
                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

                    tar --exclude='.git' --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 MỚI (BÀI 22): ĐẨY ARTIFACT VÀ KÍCH HOẠT DEPLOY TRÊN STAGING SERVER
        stage('6. Deploy to Staging Server') {
            // Tự động deploy lên Staging khi code được đẩy vào nhánh develop hoặc main
            when {
                anyOf {
                    branch 'develop'
                    branch 'main'
                }
            }
            steps {
                echo "=== Đang truyền gói ${env.ARTIFACT_NAME} lên Staging Server qua SSH Publisher ==="
                sshPublisher(
                    failOnError: true,
                    publishers: [
                        sshPublisherDesc(
                            configName: 'Staging-Monolith-Server',
                            verbose: true,
                            transfers: [
                                sshTransfer(
                                    sourceFiles: "${env.ARTIFACT_NAME}, ${env.ARTIFACT_NAME}.sha256",
                                    remoteDirectory: 'artifacts',
                                    execTimeout: 300000,
                                    execCommand: """
                                        set -e # Dừng ngay lập tức nếu có bất kỳ lệnh nào dưới đây bị lỗi
                                        
                                        APP_DIR="/var/www/monolith-app"
                                        RELEASE_DIR="\${APP_DIR}/releases/${env.BUILD_VERSION}"
                                        
                                        echo "1. Kiểm tra tính toàn vẹn của gói Artifact (SHA256 Checksum)..."
                                        cd \${APP_DIR}/artifacts
                                        sha256sum -c ${env.ARTIFACT_NAME}.sha256
                                        
                                        echo "2. Giải nén mã nguồn vào thư mục Release mới: \${RELEASE_DIR}"
                                        mkdir -p \${RELEASE_DIR}
                                        tar -xzf ${env.ARTIFACT_NAME} -C \${RELEASE_DIR}
                                        
                                        echo "3. Liên kết (Symlink) file .env và thư mục storage dùng chung..."
                                        ln -sfn \${APP_DIR}/shared/.env \${RELEASE_DIR}/.env
                                        rm -rf \${RELEASE_DIR}/storage
                                        ln -sfn \${APP_DIR}/shared/storage \${RELEASE_DIR}/storage
                                        
                                        echo "4. Chạy Database Migration và tối ưu Cache..."
                                        cd \${RELEASE_DIR}
                                        php artisan migrate --force
                                        php artisan config:cache
                                        php artisan route:cache
                                        
                                        echo "5. Tráo đổi Atomic Symlink 'current' sang bản mới..."
                                        ln -sfn \${RELEASE_DIR} \${APP_DIR}/current
                                        
                                        echo "6. Reload dịch vụ PHP-FPM & Worker..."
                                        sudo systemctl reload php8.2-fpm
                                        sudo supervisorctl restart all || true
                                        
                                        echo "7. Dọn dẹp file nén tạm trong thư mục artifacts..."
                                        rm -f \${APP_DIR}/artifacts/${env.ARTIFACT_NAME}*
                                        
                                        echo "✅ TRIỂN KHAI LÊN STAGING THÀNH CÔNG RỰC RỠ: ${env.BUILD_VERSION}"
                                    """
                                )
                            ]
                        )
                    ]
                )
            }
        }
    }

    post {
        always {
            sh "rm -rf reports/ version.json .env ${env.ARTIFACT_NAME}*"
        }
    }
}

6. Mẹo thực chiến: Khi nào nên dùng SSH Publisher vs SSH Agent + rsync/scp?

Trong thực tế vận hành Jenkins, bạn nên biết thêm một lựa chọn thay thế song hành cùng SSH Publisher Plugin:

  • Ưu điểm của SSH Publisher Plugin (sshPublisher): Cấu hình tập trung máy chủ ở Manage Jenkins -> System, không cần cài đặt client scp/rsync trên Agent, cú pháp khai báo rõ ràng phần gửi file (sourceFiles) và phần chạy lệnh (execCommand).

  • Hạn chế khi viết lệnh Bash dài trong execCommand: Như bạn thấy ở Stage 6 bên trên, nếu chúng ta nhét cả trăm dòng lệnh kiểm tra backup, dừng dịch vụ, xử lý ngoại lệ, kiểm tra Health Check và tự động Rollback trực tiếp vào chuỗi execCommand: """ ... """ bên trong Jenkinsfile, tệp Jenkinsfile sẽ trở nên rất rối, khó debug và dễ bị lỗi escape ký tự $ giữa Groovy và Bash!

Vậy làm thế nào để đóng gói toàn bộ quy trình Dừng dịch vụ, Backup, Chuyển đổi, Kiểm tra sức khỏe (Health Check) và Tự động dọn dẹp các bản Release cũ thành một tệp Bash Script chuẩn chỉnh, độc lập và chống đạn trên máy chủ đích? Đó chính là nội dung chuyên sâu của Bài 23 tiếp theo!


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í