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):
-
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ómwww-datahoặc nhóm chạy ứng dụng). -
Tài khoản
deploychỉ có quyền ghi dữ liệu vào đúng thư mục/var/www/monolith-app. -
Cấp quyền
sudokhông cần mật khẩu (NOPASSWD) cho tài khoảndeploychỉ 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ặcsupervisorctl 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.pubvà dán vào tệp/home/deploy/.ssh/authorized_keystrê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
-
Vào Manage Jenkins -> Plugins -> Available plugins.
-
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).
-
Tích chọn và bấm Install.
Bước 2: Khai báo thông tin Staging Server trong System Configuration
-
Vào Manage Jenkins -> System, cuộn xuống dưới cùng tìm mục Publish over SSH.
-
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. -
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 trongJenkinsfile). -
Hostname: Nhập địa chỉ IP của Staging Server (ví dụ:
10.0.1.25hoặc103.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).
-
-
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. -
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 clientscp/rsynctrê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ỗiexecCommand: """ ... """bên trongJenkinsfile, tệpJenkinsfilesẽ 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