Hướng dẫn Setup Jenkins cho hệ thống Monolith - Bài 25: Đóng gói ứng dụng Monolith thành Docker Image và đẩy lên Docker Registry
Ở các bài học từ Bài 20 đến Bài 24, chúng ta đã làm chủ phương pháp đóng gói hệ thống Monolith truyền thống thành tệp nén .tar.gz (hoặc .jar) và đẩy qua SSH lên máy chủ Linux. Tuy nhiên, phương pháp này vẫn tồn tại một điểm yếu cố hữu của hạ tầng vật lý/VPS: "Hội chứng lệch môi trường" (Configuration Drift).
Chuyện gì sẽ xảy ra khi trên Jenkins bạn kiểm thử với PHP 8.2.15 (hoặc Go 1.22, Node 20), nhưng một quản trị viên vô tình gõ lệnh apt upgrade trên máy chủ Staging khiến PHP bị nâng lên bản 8.3 hoặc thiếu mất extension php-gd, libpq-dev? Ứng dụng sẽ lập tức báo lỗi dù gói Artifact hoàn toàn không đổi!
Giải pháp triệt để nhất của kiến trúc hiện đại là đóng gói cả Mã nguồn (Code) + Thư viện (Dependencies) + Hệ điều hành & Runtime (OS/PHP-FPM/Nginx/Libs) vào một khối bất biến duy nhất gọi là Docker Image, sau đó đẩy lên kho lưu trữ tập trung (Docker Registry).
1. Tại sao Docker Image lại là "Mảnh ghép hoàn hảo" cho hệ thống Monolith?
Nhiều người lầm tưởng Docker chỉ dành cho Microservices. Thực tế, hệ thống Monolith mới là đối tượng hưởng lợi lớn nhất khi chuyển sang đóng gói bằng Docker trên Jenkins:
| Bài toán thực tế của Monolith | Khi dùng gói nén .tar.gz truyền thống |
Khi đóng gói thành Docker Image |
|---|---|---|
| Cài đặt thư viện hệ thống ngầm | Monolith thường cần rất nhiều gói OS (wkhtmltopdf, ffmpeg, imagick, supervisor, oracle-instantclient). Phải cài tay trên từng server. |
Khai báo 1 lần duy nhất trong Dockerfile. Mọi server chỉ cần cài đúng Docker Engine là chạy giống nhau 100%. |
Mở rộng quy mô (Auto-Scaling) |
Khi lễ/Tết tăng tải, dựng thêm 5 VPS mới và cài đặt môi trường cho Monolith mất hàng giờ đồng hồ. | Bật thêm 5 máy chủ mới, gõ lệnh docker pull và chạy trong vòng 30 giây. |
| Nâng cấp phiên bản ngôn ngữ | Nâng từ PHP 8.1 lên 8.3 trên server thật cực kỳ rủi ro, dễ làm chết ứng dụng đang chạy. | Chỉ cần đổi dòng FROM php:8.3-fpm-alpine trong Dockerfile, build image mới và chạy song song với image cũ! |
| Chuẩn bị cho Blue/Green & K8s | Khó chạy 2 phiên bản Monolith cùng lúc trên 1 máy chủ do trùng port/cấu hình. | Nền tảng bắt buộc để thực hiện Blue/Green Deployment và Kubernetes. |
2. Thiết kế Multi-stage Dockerfile chuẩn Production cho Monolith
Sai lầm phổ biến nhất khi viết Dockerfile cho hệ thống Monolith là nhét tất cả mọi thứ (trình biên dịch gcc, composer, npm, node_modules, thư mục .git, tests) vào chung một tầng (Single-stage). Kết quả là sinh ra một Docker Image khổng lồ nặng từ 1.5GB đến 3GB, làm nghẽn mạng khi đẩy lên Registry.
Giải pháp chuẩn là sử dụng kỹ thuật Multi-stage Build (Xây dựng đa tầng) kết hợp với tệp .dockerignore:
Bước 1: Tạo tệp .dockerignore (Đặt ở thư mục gốc của dự án)
Ngăn không cho Docker gửi các thư mục rác vào Docker Daemon lúc build:
Plaintext
.git
.gitignore
node_modules
vendor
tests
reports
storage/logs/*
storage/framework/cache/*
.env*
*.tar.gz
Jenkinsfile
sonar-project.properties
Bước 2: Viết Dockerfile đa tầng (Minh họa cho cả PHP Laravel và Golang Monolith)
Mẫu A: Multi-stage Dockerfile cho PHP / Laravel Monolith (Nặng ~120MB thay vì 1.5GB)
Dockerfile
# ==============================================================================
# STAGE 1: Biên dịch Frontend Assets (Node.js)
# ==============================================================================
FROM node:20-alpine AS frontend-builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit
COPY resources/ ./resources/
COPY vite.config.js ./
RUN npm run build
# ==============================================================================
# STAGE 2: Cài đặt PHP Dependencies (Composer)
# ==============================================================================
FROM composer:2.7 AS vendor-builder
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-interaction \
--no-plugins \
--no-scripts \
--no-dev \
--prefer-dist \
--optimize-autoloader
# ==============================================================================
# STAGE 3: Production Runtime Image (Siêu nhẹ, bảo mật với Non-root User)
# ==============================================================================
FROM php:8.2-fpm-alpine AS production
# Nhận thông tin Metadata phiên bản truyền từ Jenkins vào lúc build
ARG BUILD_VERSION="unknown"
ARG GIT_COMMIT="unknown"
LABEL maintainer="DevOps Team" \
version="${BUILD_VERSION}" \
git_commit="${GIT_COMMIT}"
# Cài đặt các C-extensions cần thiết cho Monolith (PDO MySQL, Redis, OPcache, Zip...)
RUN apk add --no-cache \
libpng-dev libzip-dev oniguruma-dev curl \
&& docker-php-ext-install pdo_mysql mbstring zip exif pcntl bcmath opcache
WORKDIR /var/www/html
# 1. Copy mã nguồn gốc của ứng dụng
COPY --chown=www-data:www-data . .
# 2. Chỉ copy thư mục thành phẩm từ Stage 1 và Stage 2 sang (Bỏ lại hoàn toàn node_modules và cache!)
COPY --from=vendor-builder --chown=www-data:www-data /app/vendor ./vendor
COPY --from=frontend-builder --chown=www-data:www-data /app/public/build ./public/build
# 3. Ghi tệp định danh version.json ngay bên trong Image
RUN printf '{"version":"%s","commit":"%s"}\n' "${BUILD_VERSION}" "${GIT_COMMIT}" > version.json
# 4. Chuyển sang chạy bằng tài khoản www-data (Tuyệt đối không chạy container bằng quyền root!)
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
Mẫu B: Multi-stage Dockerfile cho Golang Monolith (Siêu nhẹ ~25MB)
Dockerfile
# Stage 1: Build Go Binary
FROM golang:1.22-alpine AS builder
ARG BUILD_VERSION="dev"
ARG GIT_COMMIT="none"
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X 'main.AppVersion=${BUILD_VERSION}' -X 'main.GitCommit=${GIT_COMMIT}'" \
-o /bin/monolith-app ./cmd/main.go
# Stage 2: Minimal Runtime
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata curl
WORKDIR /app
COPY --from=builder /bin/monolith-app /app/monolith-app
USER nobody:nobody
EXPOSE 8080
ENTRYPOINT ["/app/monolith-app"]
3. Cấu hình kết nối Jenkins với Docker Registry (Harbor / Docker Hub / GitLab Registry / AWS ECR)
Ở Bài 3, chúng ta đã cấu hình mount /var/run/docker.sock:/var/run/docker.sock vào container Jenkins. Nhờ vậy, Jenkins có thể gọi trực tiếp lệnh docker build và docker push thông qua Docker Engine của máy chủ Host.
Để Jenkins đăng nhập và đẩy Image lên kho chứa riêng tư (Private Docker Registry như Harbor nội bộ, GitLab Container Registry hoặc Docker Hub), bạn thực hiện 2 bước sau:
-
Truy cập Manage Jenkins -> Credentials -> Global credentials -> Add Credentials.
-
Chọn thông tin:
-
Kind:
Username with password. -
Username: Tài khoản Registry (hoặc Robot Account của Harbor / GitLab Deploy Token).
-
Password: Mật khẩu hoặc Access Token.
-
ID: Đặt là
docker-registry-credentials. -
Bấm Create.
-
4. Quy tắc Đánh nhãn (Tagging) và Quét bảo mật Image trước khi Push
Khi đóng gói Docker Image cho hệ thống Monolith trên Jenkins, hãy tuân thủ 3 quy tắc thực chiến sau:
-
Đánh nhãn kép có kiểm soát (Dual Tagging):
-
Nhãn 1 (Bất biến - Immutable Tag):
[registry.yourcompany.com/monolith/core-app:$](https://registry.yourcompany.com/monolith/core-app:$){BUILD_VERSION}(ví dụ::v1.2.0-production.b45-a1b2c3d). Nhãn này dùng để Deploy và Rollback chính xác tuyệt đối. -
Nhãn 2 (Nhãn trỏ nhanh theo nhánh):
:latest-staginghoặc:latest-prod. Nhãn này giúp tầng tiếp theo dễ dàng tận dụng bộ nhớ đệm (--cache-from).
-
-
Kích hoạt
DOCKER_BUILDKIT=1: Bật công nghệ BuildKit của Docker giúp Jenkins chạy song song các Stage độc lập trongDockerfile(ví dụ: Stagefrontend-buildervà Stagevendor-builderở trên sẽ chạy cùng một lúc thay vì chờ nhau!), giảm 50% thời gian đóng gói Image. -
Quét lỗ hổng Hệ điều hành trong Image bằng
trivy image: Ở Bài 16 chúng ta dùngtrivy fsđể quét thư viện code. Bây giờ khi đã đóng gói thành Docker Image, hãy chạy thêmtrivy imageđể kiểm tra xem bản Linux Alpine/Ubuntu bên trong Image có chứa lỗ hổng bảo mật hệ điều hành nào không trước khi đẩy lên Registry!
5. Hoàn thiện Jenkinsfile: Build, Scan và Push Docker Image lên Registry
Dưới đây là tệp Jenkinsfile hoàn chỉnh cho Bài 25: Sau khi vượt qua các bước Unit Test và SonarQube ở Giai đoạn 2, Pipeline sẽ sử dụng Docker BuildKit để đóng gói ứng dụng Monolith thành Docker Image, quét bảo mật Image, đẩy lên Private Registry và tự động dọn dẹp Image tạm trên máy chủ Jenkins:
Groovy
pipeline {
agent any
options {
buildDiscarder(logRotator(numToKeepStr: '30'))
timeout(time: 30, unit: 'MINUTES')
timestamps()
disableConcurrentBuilds()
}
environment {
APP_NAME = "monolith-core-system"
// KHAI BÁO ĐỊA CHỈ PRIVATE DOCKER REGISTRY CỦA CÔNG TY
// (Ví dụ: harbor.yourcompany.com, registry.gitlab.com, hoặc docker.io)
DOCKER_REGISTRY = "registry.yourcompany.com"
DOCKER_REPO = "${DOCKER_REGISTRY}/monolith/${APP_NAME}"
REGISTRY_CRED = "docker-registry-credentials"
// Bật Docker BuildKit để biên dịch song song các stage trong Dockerfile
DOCKER_BUILDKIT = "1"
CACHE_ROOT = "/var/jenkins_home/.cache"
COMPOSER_CACHE_DIR = "${CACHE_ROOT}/composer"
SONAR_USER_HOME = "${CACHE_ROOT}/sonar"
}
stages {
stage('1. 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} ${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}"
env.BRANCH_TAG = "latest-prod"
} else {
env.BUILD_VERSION = "${env.LATEST_SEMVER_TAG}-${env.CLEAN_BRANCH}.${env.BUILD_NUMBER}-${env.GIT_SHORT_HASH}"
env.BRANCH_TAG = "latest-${env.CLEAN_BRANCH}"
}
// Định danh đầy đủ của Docker Image sẽ tạo ra
env.IMAGE_FULL_VERSION = "${env.DOCKER_REPO}:${env.BUILD_VERSION}"
env.IMAGE_BRANCH_LATEST = "${env.DOCKER_REPO}:${env.BRANCH_TAG}"
currentBuild.displayName = "🐳 ${env.BUILD_VERSION}"
}
}
}
stage('2. CI: Unit Tests & SonarQube Gate') {
environment {
APP_ENV = "testing"
DB_CONNECTION = "sqlite"
DB_DATABASE = ":memory:"
XDEBUG_MODE = "coverage"
}
steps {
echo "=== Cài đặt thư viện, chạy Unit Test và kiểm tra SonarQube Quality Gate ==="
sh '''
composer install --no-interaction --prefer-dist
mkdir -p reports
./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
}
}
post {
always {
junit testResults: 'reports/junit.xml', allowEmptyResults: false
}
}
}
// STAGE 3 (BÀI 25): ĐÓNG GÓI ỨNG DỤNG MONOLITH THÀNH DOCKER IMAGE
stage('3. Build Docker Image (Multi-stage)') {
steps {
echo "=== Đang đóng gói Docker Image: ${env.IMAGE_FULL_VERSION} ==="
sh """
docker build \\
--pull \\
--build-arg BUILD_VERSION=${env.BUILD_VERSION} \\
--build-arg GIT_COMMIT=${env.GIT_SHORT_HASH} \\
-t ${env.IMAGE_FULL_VERSION} \\
-t ${env.IMAGE_BRANCH_LATEST} \\
-f Dockerfile .
# Kiểm tra dung lượng thực tế của Docker Image vừa tạo
docker images ${env.DOCKER_REPO}
"""
}
}
// STAGE 4 (BÀI 25): QUÉT LỖ HỔNG BẢO MẬT CỦA CONTAINER IMAGE TRƯỚC KHI PUSH
stage('4. Scan Docker Image Security') {
steps {
echo "=== Quét lỗ hổng bảo mật tầng OS & Packages bên trong Docker Image bằng Trivy ==="
sh """
trivy image \\
--cache-dir ${CACHE_ROOT}/trivy \\
--severity HIGH,CRITICAL \\
--ignore-unfixed \\
--exit-code 1 \\
${env.IMAGE_FULL_VERSION}
"""
}
}
// STAGE 5 (BÀI 25): ĐĂNG NHẬP AN TOÀN VÀ ĐẨY IMAGE LÊN DOCKER REGISTRY
stage('5. Push Image to Docker Registry') {
steps {
echo "=== Đang đẩy Docker Image lên kho lưu trữ: ${env.DOCKER_REGISTRY} ==="
withCredentials([usernamePassword(
credentialsId: "${env.REGISTRY_CRED}",
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS'
)]) {
sh '''
# Kỹ thuật bảo mật: Dùng --password-stdin để không bị lộ mật khẩu trong tiến trình ps aux
echo "$REG_PASS" | docker login ${DOCKER_REGISTRY} -u "$REG_USER" --password-stdin
# Đẩy cả tag định danh phiên bản cụ thể và tag nhánh lên Registry
docker push ${IMAGE_FULL_VERSION}
docker push ${IMAGE_BRANCH_LATEST}
# Đăng xuất ngay sau khi đẩy xong
docker logout ${DOCKER_REGISTRY}
'''
}
}
}
}
post {
always {
echo "=== Dọn dẹp Docker Image tạm trên máy chủ Jenkins để tránh đầy ổ cứng ==="
sh '''
rm -rf reports/ .env*
# Xóa tag image vừa build trên máy Jenkins (vì đã nằm an toàn trên Registry)
docker rmi ${IMAGE_FULL_VERSION} ${IMAGE_BRANCH_LATEST} 2>/dev/null || true
# Dọn dẹp các dangling images (image mồ côi sinh ra trong lúc multi-stage build)
docker image prune -f || true
'''
}
}
}
6. Ba chi tiết kỹ thuật "Đắt giá" trong Pipeline đóng gói Docker bên trên
-
Đăng nhập bằng
--password-stdinthay vì-p: Trong Stage 5, thay vì gõdocker login -u $REG_USER -p $REG_PASS, chúng ta dùngecho "$REG_PASS" | docker login ... --password-stdin. Điều này ngăn chặn mật khẩu Registry hiển thị dạng rõ (plain-text) trong bảng tiến trình hệ điều hành Linux (ps aux) và loại bỏ cảnh báo bảo mật của Docker CLI. -
Tham số
--ignore-unfixedkhi quét Trivy Image: Các bản phân phối Linux gốc (như Debian, Ubuntu, Alpine) đôi khi có những lỗ hổng nhỏ mà chính nhà phát hành hệ điều hành chưa tung ra bản vá. Cờ--ignore-unfixedgiúp Trivy chỉ chặn Pipeline đối với những lỗ hổng đã có bản vá chính thức mà bạn chưa cập nhật (do dùng Base Image quá cũ), kết hợp với cờ--pullở lệnhdocker buildđể luôn lấy bản vá bảo mật mới nhất của Base Image. -
Cơ chế tự dọn dẹp
docker rmiở khốipost { always }: Nếu một ngày bạn build 20 lần, mỗi Docker Image nặng 150MB sẽ ngốn mất 3GB ổ cứng của máy chủ Jenkins chỉ sau 1 ngày! Ngay sau khidocker pushthành công lên Registry, lệnhdocker rmivàdocker image prune -fsẽ xóa ngay bản lưu cục bộ trên Jenkins, giữ cho ổ cứng máy chủ CI/CD luôn nhẹ nhàng.
All rights reserved