Hướng dẫn Setup Jenkins cho hệ thống Monolith - Bài 30: Thực hành quy trình Blue/Green Deployment cơ bản cho hệ thống Monolith
Chúng ta đã đi đến bài học cuối cùng và cũng là đỉnh cao kỹ thuật của Giai đoạn 3: Triển khai (Deployment) và Quản lý Môi trường.
Hãy nhìn lại kịch bản triển khai Docker ở Bài 26 và 27: Khi chúng ta chạy lệnh docker stop monolith-core-app để xóa container cũ và bật container mới lên, trong khoảng 3 đến 5 giây giao thời đó, máy chủ hoàn toàn không có container nào lắng nghe ở cổng 8000. Nếu hệ thống Monolith của bạn là một sàn Thương mại điện tử đang có 2.000 khách hàng bấm nút "Thanh toán" đúng vào 3 giây đó, toàn bộ 2.000 giao dịch sẽ nhận lỗi 502 Bad Gateway từ Nginx!
Làm thế nào để triển khai phiên bản Docker Monolith mới lên một máy chủ Production duy nhất mà không làm rớt dù chỉ 1 request của khách hàng (Zero-Downtime Deployment)? Giải pháp chuẩn mực nhất chính là kỹ thuật Blue/Green Deployment (Triển khai Xanh Dương / Xanh Lá) kết hợp cùng Nginx Reverse Proxy.
1. Nguyên lý hoạt động của Blue/Green Deployment trên một máy chủ
Thay vì dùng 1 tên container và 1 cổng cố định, chúng ta chia môi trường chạy trên máy chủ Production thành 2 khe (Slots) song song:
-
Khe BLUE (Xanh Dương): Container tên là
monolith-blue, lắng nghe ở cổng nội bộ127.0.0.1:8001. -
Khe GREEN (Xanh Lá): Container tên là
monolith-green, lắng nghe ở cổng nội bộ127.0.0.1:8002.
Trước mặt 2 khe này là Nginx (lắng nghe ở cổng 80/443 công khai) đóng vai trò là người điều phối giao thông:
Plaintext
┌──► [Container: monolith-blue] (Port 8001) - Đang chạy v1.1 (ACTIVE)
[Khách hàng] ──► [Nginx]
(Port 443) └──x [Container: monolith-green] (Port 8002) - Đang bật v1.2 & Test ngầm (IDLE)
Quy trình chuyển giao "Không độ trễ" diễn ra qua 4 nhịp:
-
Xác định màu đang chạy: Script kiểm tra xem Nginx đang trỏ vào
blue(8001) haygreen(8002). Giả sử hiện tạiblueđang phục vụ khách hàng. -
Khởi động bản mới ở khe đối diện (
green): Bật containermonolith-greenvới Docker Image mới ở cổng8002. Lúc này khách hàng vẫn đang làm việc bình thường trên cổng8001, hoàn toàn không bị ảnh hưởng. -
Kiểm tra sức khỏe ngầm (Private Smoke Test): Script gọi trực tiếp vào
[http://127.0.0.1:8002/api/health](http://127.0.0.1:8002/api/health)và chạy xong toàn bộmigratetrên containergreen. Nếugreenbị lỗi khởi động, script chỉ việc xóagreenđi — khách hàng ởbluekhông hề hay biết có một cuộc deploy vừa thất bại! -
Bẻ ghi lưu lượng (Traffic Cutover) & Graceful Shutdown: Khi
greenđã trả về200 OK, script cập nhật tệp upstream của Nginx sang cổng8002và chạy lệnhsudo nginx -s reload. Nginx sẽ cho phép các request đang xử lý dở ởbluechạy nốt cho xong rồi mới đóng kết nối, đồng thời mọi request mới đều đi vàogreen!
2. Cấu hình Nginx Dynamic Upstream trên máy chủ Production
Để kịch bản Blue/Green có thể tráo đổi cổng mà không cần sửa trực tiếp vào tệp cấu hình chính của Nginx, bạn hãy tách phần khai báo cổng đích (upstream) ra một tệp riêng biệt.
Thực hiện trên Production Server:
Bước 1: Tạo tệp chứa cổng Upstream hiện hành
Bash
# Tạo thư mục quản lý trạng thái Blue/Green
sudo mkdir -p /var/www/monolith-app/nginx
# Khởi tạo mặc định ban đầu trỏ vào khe BLUE (Cổng 8001)
sudo tee /var/www/monolith-app/nginx/active_upstream.conf << 'EOF'
server 127.0.0.1:8001;
EOF
sudo chown -R deploy:www-data /var/www/monolith-app/nginx
Bước 2: Cấu hình Virtual Host của Nginx đọc từ tệp active_upstream.conf
Mở tệp cấu hình domain của bạn tại /etc/nginx/sites-available/monolith.conf:
Nginx
# Khối upstream đọc động từ file cấu hình bên ngoài
upstream monolith_backend {
include /var/www/monolith-app/nginx/active_upstream.conf;
keepalive 32;
}
server {
listen 80;
server_name yourcompany.com;
location / {
proxy_pass http://monolith_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Tự động chuyển sang thử lại nếu 1 request đang rơi vào đúng mili-giây chuyển giao
proxy_next_upstream error timeout http_502 http_503 http_504;
}
}
(Lưu ý: Nếu Docker Container của bạn chạy php-fpm thuần cổng 9000 thay vì gói kèm HTTP Server bên trong, bạn chỉ cần đổi proxy_pass http://monolith_backend; thành fastcgi_pass monolith_backend; — nguyên lý hoạt động giống hệt 100%).
Kiểm tra cú pháp và cấp quyền sudo cho user deploy được phép reload Nginx:
Bash
sudo nginx -t && sudo systemctl reload nginx
# Đảm bảo trong /etc/sudoers.d/deploy-monolith (Bài 22) đã có dòng này:
# deploy ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload, /bin/systemctl reload nginx
3. Xây dựng Script deploy-blue-green.sh tự động hóa hoàn toàn trên Server
Hãy tạo tệp /var/www/monolith-app/deploy-blue-green.sh trên máy chủ Production. Đây là "trái tim" điều phối toàn bộ quá trình chuyển đổi giữa Blue và Green:
Bash
#!/usr/bin/env bash
# ==============================================================================
# BASH SCRIPT BLUE/GREEN ZERO-DOWNTIME DEPLOYMENT CHO DOCKER MONOLITH
# Cách gọi: ./deploy-blue-green.sh <DOCKER_IMAGE_FULL_URL> <RUN_MIGRATION_BOOL>
# ==============================================================================
set -euo pipefail
NEW_IMAGE_URL="${1:-}"
RUN_MIGRATION="${2:-true}"
APP_DIR="/var/www/monolith-app"
SHARED_DIR="${APP_DIR}/shared"
UPSTREAM_CONF="${APP_DIR}/nginx/active_upstream.conf"
STATE_FILE="${APP_DIR}/nginx/active_color.txt"
NETWORK_NAME="monolith-net"
if [ -z "${NEW_IMAGE_URL}" ]; then
echo "❌ LỖI: Thiếu tham số Docker Image URL!"
exit 1
fi
docker network create "${NETWORK_NAME}" 2>/dev/null || true
# 1. Xác định môi trường nào đang chạy (ACTIVE) và môi trường nào sẽ nhận bản mới (TARGET)
if grep -q "8001" "${UPSTREAM_CONF}" 2>/dev/null; then
ACTIVE_COLOR="blue"
ACTIVE_PORT="8001"
TARGET_COLOR="green"
TARGET_PORT="8002"
else
ACTIVE_COLOR="green"
ACTIVE_PORT="8002"
TARGET_COLOR="blue"
TARGET_PORT="8001"
fi
ACTIVE_CONTAINER="monolith-${ACTIVE_COLOR}"
TARGET_CONTAINER="monolith-${TARGET_COLOR}"
echo "=================================================================="
echo "🔵🟢 BẮT ĐẦU CHIẾN LƯỢC BLUE/GREEN DEPLOYMENT"
echo " - Môi trường đang phục vụ khách hàng (ACTIVE) : [${ACTIVE_COLOR^^}] (Port ${ACTIVE_PORT})"
echo " - Môi trường chuẩn bị khởi tạo mới (TARGET) : [${TARGET_COLOR^^}] (Port ${TARGET_PORT})"
echo " - Phiên bản Image mới : ${NEW_IMAGE_URL}"
echo "=================================================================="
# 2. Kéo Docker Image mới về máy chủ (Khách hàng ở nhánh ACTIVE vẫn dùng bình thường)
echo "[Bước 1/6] Đang tải Docker Image mới từ Registry..."
docker pull "${NEW_IMAGE_URL}"
# 3. Dọn dẹp container ở khe TARGET (nếu còn tồn tại từ lần chạy trước) và khởi động bản mới
echo "[Bước 2/6] Khởi động container [${TARGET_CONTAINER}] tại cổng nội bộ ${TARGET_PORT}..."
docker stop "${TARGET_CONTAINER}" 2>/dev/null || true
docker rm "${TARGET_CONTAINER}" 2>/dev/null || true
docker run -d \
--name "${TARGET_CONTAINER}" \
--restart unless-stopped \
--network "${NETWORK_NAME}" \
-p "127.0.0.1:${TARGET_PORT}:8080" \
--env-file "${SHARED_DIR}/.env" \
-v "${SHARED_DIR}/storage:/var/www/html/storage" \
"${NEW_IMAGE_URL}"
# 4. Kiểm tra sức khỏe ngầm (Smoke Test) trên cổng TARGET_PORT (Thử tối đa 10 lần, mỗi lần cách nhau 3 giây)
echo "[Bước 3/6] Đang kiểm tra sức khỏe (Health Check) trên cổng mới ${TARGET_PORT}..."
HEALTHY=false
for i in {1..10}; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${TARGET_PORT}/api/health" || echo "000")
if [ "${HTTP_CODE}" -eq 200 ]; then
HEALTHY=true
echo "✅ Lần thử #${i}: Container [${TARGET_CONTAINER}] phản hồi HTTP 200 OK!"
break
fi
echo "⏳ Lần thử #${i}: Đang chờ container khởi động hoàn tất (Mã hiện tại: ${HTTP_CODE})..."
sleep 3
done
# Nếu sau 30 giây mà container mới vẫn không khỏe -> Hủy bỏ ngay, KHÔNG đổi Nginx!
if [ "${HEALTHY}" = false ]; then
echo "❌ BÁO ĐỘNG ĐỎ: Môi trường mới [${TARGET_COLOR^^}] không vượt qua bài kiểm tra Health Check!"
echo "🛡️ Giữ nguyên 100% lưu lượng khách hàng ở môi trường cũ [${ACTIVE_COLOR^^}]. Tiến hành xóa container lỗi..."
docker logs --tail 30 "${TARGET_CONTAINER}" || true
docker stop "${TARGET_CONTAINER}" || true
docker rm "${TARGET_CONTAINER}" || true
exit 1
fi
# 5. Chạy Database Migration trên container mới trước khi mở cổng đón khách
if [ "${RUN_MIGRATION}" = "true" ]; then
echo "[Bước 4/6] Thực thi Database Migration trên [${TARGET_CONTAINER}]..."
docker exec "${TARGET_CONTAINER}" php artisan migrate --force
else
echo "[Bước 4/6] Bỏ qua bước Database Migration theo cấu hình."
fi
# 6. BẺ GHI LƯU LƯỢNG (TRAFFIC CUTOVER): Chuyển Nginx sang cổng TARGET_PORT
echo "[Bước 5/6] Chuyển đổi lưu lượng Nginx sang môi trường [${TARGET_COLOR^^}] (Port ${TARGET_PORT})..."
echo "server 127.0.0.1:${TARGET_PORT};" > "${UPSTREAM_CONF}"
echo "${TARGET_COLOR}" > "${STATE_FILE}"
# Reload Nginx mượt mà (Zero-Downtime)
sudo systemctl reload nginx
echo "🎉 Đã chuyển 100% lưu lượng truy cập sang [${TARGET_COLOR^^}] mà không gây gián đoạn!"
# 7. Chờ 5 giây cho các request cũ trên ACTIVE_CONTAINER xử lý nốt (Connection Draining) rồi tắt
echo "[Bước 6/6] Đợi 5 giây để hoàn tất các request đang xử lý dở trên [${ACTIVE_CONTAINER}]..."
sleep 5
if [ "$(docker ps -q -f name=^/${ACTIVE_CONTAINER}$)" ]; then
echo "🛑 Tạm dừng container cũ [${ACTIVE_CONTAINER}] (Giữ lại ở trạng thái Exited để sẵn sàng Rollback trong 1 giây nếu cần)..."
docker stop "${ACTIVE_CONTAINER}" || true
fi
docker image prune -f >/dev/null 2>&1 || true
echo "✅ HOÀN TẤT QUY TRÌNH BLUE/GREEN DEPLOYMENT! MÔI TRƯỜNG ĐANG CHẠY: [${TARGET_COLOR^^}]"
Cấp quyền thực thi cho script:
Bash
sudo chmod +x /var/www/monolith-app/deploy-blue-green.sh
sudo chown deploy:www-data /var/www/monolith-app/deploy-blue-green.sh
Tuyệt chiêu "Giữ Container cũ ở trạng thái Stopped để Rollback trong 0.5 giây": Hãy để ý ở Bước 6 bên trên: Chúng ta chỉ dùng lệnh
docker stop "${ACTIVE_CONTAINER}"chứ không dùngdocker rmđể xóa container cũ đi! Tại sao? Vì nếu 15 phút sau khi lên bảngreen, khách hàng phát hiện ra một lỗi logic nghiệp vụ tinh vi mà/api/healthkhông bắt được, bạn không cần phảidocker pullhay tạo lại container từ đầu. Bạn chỉ cần gõ lệnh bật lại containerblueđang nằm sẵn (docker start monolith-blue) và reload Nginx về cổng8001— toàn bộ quá trình Rollback chỉ mất đúng 0.5 giây!
4. Tích hợp Stage Blue/Green Deployment hoàn chỉnh vào Jenkinsfile
Bây giờ, chúng ta hãy ghép nối kịch bản deploy-blue-green.sh vào Stage cuối cùng của Jenkinsfile (tiếp nối từ Bài 29) để hoàn thiện toàn bộ chu trình Giai đoạn 3:
Groovy
// =====================================================================
// STAGE 3 (BÀI 30): THỰC THI ZERO-DOWNTIME BLUE/GREEN DEPLOYMENT
// =====================================================================
stage('3. Zero-Downtime Blue/Green Deployment') {
agent any
steps {
script {
echo "=== Bắt đầu quy trình Blue/Green Deployment cho phiên bản ${env.BUILD_VERSION} lên [${env.SSH_SERVER_NAME}] ==="
// Mặc định chạy migration nếu ở Staging, hoặc lấy theo lựa chọn của Tech Lead ở bước Manual Approval (Bài 29)
def shouldMigrate = env.RUN_DB_MIGRATION ?: 'true'
withCredentials([file(credentialsId: "${env.ENV_CRED_ID}", variable: 'SECRET_ENV_FILE')]) {
sh 'cp $SECRET_ENV_FILE .env.target'
sshPublisher(
failOnError: true,
publishers: [
sshPublisherDesc(
configName: "${env.SSH_SERVER_NAME}",
verbose: true,
transfers: [
// 1. Đồng bộ tệp cấu hình .env mới nhất
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
'''
),
// 2. Kích hoạt kịch bản tráo đổi Blue/Green không độ trễ
sshTransfer(
sourceFiles: '',
remoteDirectory: '',
execTimeout: 300000,
execCommand: "/var/www/monolith-app/deploy-blue-green.sh ${env.IMAGE_FULL_URL} ${shouldMigrate}"
)
]
)
]
)
}
sendDeployResultNotification('SUCCESS (BLUE/GREEN ZERO-DOWNTIME)')
}
}
post {
always {
sh 'rm -f .env.target'
}
}
}
5. Lưu ý sống còn khi áp dụng Blue/Green cho hệ thống Monolith: "Bẫy tương thích ngược của Database"
Trong kiến trúc Blue/Green, có một khoảng thời gian ngắn (từ Bước 4 đến Bước 5 trong script) khi lệnh migrate đã chạy xong trên Database chung, nhưng người dùng vẫn đang truy cập vào container phiên bản cũ (ACTIVE). Đồng thời, nếu bạn cần Rollback khẩn cấp về container cũ, code cũ sẽ phải làm việc với Database đã được migrate lên bản mới!
Do đó, khi viết Database Migration cho hệ thống Monolith chạy Blue/Green, hãy luôn tuân thủ quy tắc Expand and Contract (Mở rộng trước, Thu hẹp sau):
-
Nên làm: Thêm cột mới cho phép
NULLhoặc có giá trịDEFAULT; tạo bảng mới. (Code cũ chạy trên DB mới vẫn hoạt động bình thường). -
Tuyệt đối tránh làm trong 1 lần deploy: Đổi tên cột (
RENAME COLUMN) hoặc xóa cột (DROP COLUMN) mà code cũ đangSELECT. Hãy chia làm 2 kỳ release: Release 1 cập nhật code không dùng cột đó nữa -> Release 2 mới chạy lệnh xóa cột.
All rights reserved