Khi vận hành hàng trăm website, ba thao tác rủi ro nhất thường không phải sự cố mà là những việc làm theo yêu cầu: deploy một site mới, đổi domain cho site đang chạy và chuyển site sang Cloud VPS khác. Mỗi thao tác chạm vào bốn nơi cùng lúc (balancer, minion, database, DNS), hỏng giữa chừng là site rơi vào trạng thái nửa cũ nửa mới. Tài liệu này hướng dẫn viết các thao tác đó thành pipeline Jenkins có rollback: backup database trước, làm từng bước có cờ trạng thái, kiểm tra HTTP sau cùng và chỉ quay lui những gì đã thay đổi, kèm một cái bẫy trong khối post khiến rollback báo "xong" nhưng không khôi phục được gì.
1. Vì sao cần pipeline có rollback
Đổi domain cho một site Laravel trên Cloud VPS, làm tay, gồm chừng ấy bước:
- Dump database, thay mọi URL
https://old.examplethànhhttps://new.example, import lại. - Đổi tên thư mục site trên minion, sửa
APP_URLtrong.env. - Sửa file cấu hình site trên minion, sửa cron và
supervisord.inichứa đường dẫn cũ. - Tạo file cấu hình mới trên balancer, xin chứng chỉ SSL cho domain mới.
- Xoá cache Laravel, reload PHP-FPM, kiểm tra site.
Làm tay thì dễ quên cron, và nếu lỗi ở bước 4 (database đã đổi URL mà balancer chưa nhận domain mới), người vận hành phải nhớ chính xác mình đã làm tới đâu để quay lại. Pipeline có rollback giải quyết đúng chỗ này: máy nhớ thay người. Cách Jenkins điều khiển cả cụm qua SSH có trong bài Jenkins làm "control plane" cho hàng trăm website.
| Pipeline | Việc chính | Rollback khi lỗi |
|---|---|---|
| Deploy site từ repo | Tạo database, clone code, sinh .env, cấu hình nginx minion và balancer, xin SSL | Xoá cấu hình nginx, chứng chỉ, code, database và user vừa tạo |
| Đổi domain | Dump DB và thay URL, đổi thư mục, .env, cron, supervisor, nginx, SSL | Trả từng thứ về tên cũ, import lại bản dump gốc |
| Chuyển VPS | Chuyển site sang minion, balancer hoặc database khác, rồi xoá dữ liệu ở máy cũ | Gỡ những gì đã tạo trên máy mới, máy cũ vẫn nguyên |
| Migrate hàng loạt | Copy sang máy mới, chuyển traffic, giữ nguyên máy cũ | Chép lại file cấu hình balancer đã backup |
2. Khung chung: cờ trạng thái cho từng bước
Nguyên tắc cốt lõi của rollback là chỉ hoàn tác những gì đã thực sự thay đổi. Job lỗi ngay ở bước kiểm tra tham số thì rollback không được đụng vào database; lỗi sau khi đã đổi tên thư mục thì rollback phải đổi lại. Dùng một tập cờ khởi tạo bằng false ở stage đầu, mỗi bước làm xong mới bật cờ của nó:
stage('Validate Parameters') {
steps { script {
// ... kiểm tra tham số, chuẩn hoá domain ...
env.db_updated = 'false'
env.folder_renamed = 'false'
env.balancer_nginx_updated = 'false'
env.post_setup_phase = 'false'
env.BK = "/tmp/${env.OLD_DOMAIN}_${env.BUILD_NUMBER}" // tiền tố file backup
} }
}
stage('Backup & Update Database') {
steps { script {
sh "ssh deploy@ops \"mysqldump --login-path=${env.DB_HOST} --single-transaction \
${env.DB_DATABASE} > ${env.BK}.sql\""
sh "ssh deploy@ops \"sed 's|https://${env.OLD_DOMAIN}|https://${env.NEW_DOMAIN}|g' \
${env.BK}.sql > ${env.BK}_new.sql\""
env.db_updated = 'true' // bật TRƯỚC lệnh import: import lỗi giữa chừng vẫn được rollback
sh "ssh deploy@ops \"mysql --login-path=${env.DB_HOST} ${env.DB_DATABASE} < ${env.BK}_new.sql\""
} }
}Bản dump gốc được giữ riêng, bản đã thay URL là file khác; rollback database chính là import lại bản gốc. Với bước ghi dữ liệu như import, cờ nên bật ngay trước lệnh: nếu import lỗi giữa chừng, database có thể đã bị ghi một phần và vẫn cần được khôi phục. Với bước tạo mới (thư mục, file cấu hình), bật cờ sau khi lệnh xong để rollback không xoá thứ chưa từng tồn tại. Tên file backup kèm số build để các lần chạy không ghi đè nhau.
Khối failure: hoàn tác theo thứ tự ngược
Khi một stage ném lỗi, Jenkins nhảy vào post { failure { ... } }. Ở đó pipeline đọc cờ và hoàn tác theo thứ tự ngược với lúc làm: balancer trước, rồi minion, thư mục, cuối cùng là database:
failure {
script {
if (env.post_setup_phase == 'true') {
echo "Phần chính đã xong, không rollback."
return
}
if (env.balancer_nginx_updated == 'true') {
sh "ssh deploy@${params.balancer} \"sudo rm -f /etc/nginx/conf.d/${env.NEW_DOMAIN}.conf \
&& sudo cp ${env.BK}.conf.bak /etc/nginx/conf.d/${env.OLD_DOMAIN}.conf \
&& sudo nginx -t && sudo systemctl reload nginx\" || true"
}
if (env.folder_renamed == 'true') {
sh "ssh deploy@${params.minion} \"cd /var/www && mv '${env.NEW_DOMAIN}' '${env.OLD_DOMAIN}'\" || true"
}
// database: xem mục 4, phải kiểm tra file backup trước khi restore
}
}Cờ post_setup_phase đánh dấu ranh giới "đã xong phần chính". Sau ranh giới đó (xoá cache, chạy lệnh artisan bổ sung), lỗi không còn là lý do để rollback: domain mới đã chạy, quay lui lúc này còn gây hại hơn. Các bước sau ranh giới được bọc try/catch và chỉ đặt build UNSTABLE.
3. Kiểm tra HTTP: thước đo cuối cùng
nginx -t pass, systemctl reload không lỗi, nhưng site vẫn có thể trả 502 vì sai socket, sai quyền thư mục hay thiếu biến trong .env. Vì vậy stage cuối của mọi pipeline là gọi thử từng domain:
stage('Verify Website') {
steps { script {
for (domain in env.domains_list.split(',')) {
def code = sh(script: "curl -s -L -w '%{http_code}' -o /dev/null \
--connect-timeout 10 --max-time 30 'https://${domain}'", returnStdout: true).trim()
echo "${code == '200' ? 'OK' : 'WARN'} https://${domain} - HTTP ${code}"
if (code != '200') { currentBuild.result = 'UNSTABLE' }
}
} }
}Với pipeline deploy site mới, bước kiểm tra nên không kích hoạt rollback, chỉ đánh dấu UNSTABLE: HTTP khác 200 lúc này thường do DNS chưa trỏ xong, và rollback tự động sẽ xoá sạch một site gần như đã chạy được. Site đang sống thì có thể chọn ngược lại. Dù thế nào, không coi UNSTABLE là thành công.
4. Cái bẫy: post always chạy trước failure
Một mẫu dọn dẹp rất hay gặp, đặt ở cuối post, sau failure:
post {
failure {
// ... rollback, cần ${BK}.sql và ${BK}.conf.bak
}
always {
sh "ssh deploy@ops 'rm -f ${env.BK}*.sql' || true"
sh "ssh deploy@${params.balancer} 'rm -f ${env.BK}.conf.bak' || true"
}
}Đọc từ trên xuống, ai cũng nghĩ always chạy sau cùng. Sai. Theo tài liệu cú pháp post của Jenkins Declarative Pipeline, các điều kiện được thực thi theo thứ tự cố định, không theo thứ tự viết trong file: always, changed, fixed, regression, aborted, failure, success, unstable, unsuccessful, rồi cleanup.
Nghĩa là khi job lỗi, always chạy trước và xoá bản dump gốc cùng file cấu hình balancer đã backup. Sau đó failure mới chạy rollback, gọi restore lên một file không còn tồn tại. Nếu mọi lệnh rollback có đuôi || true để một bước hỏng không chặn bước sau, lỗi "No such file" bị nuốt mất và log in ra "Rollback completed" rất yên tâm. Database vẫn mang URL mới, balancer không có file cấu hình cũ để chép lại. Bẫy này vô hình trong mọi lần chạy thành công, chỉ lộ ra đúng lúc cần rollback nhất. Cách sửa:
- Dời việc dọn file tạm sang
cleanup, điều kiện duy nhất được đảm bảo chạy sau tất cả, kể cả saufailure. - Kiểm tra file backup tồn tại trước khi restore (
test -s), không dựa vào|| true. Thiếu file thì báo đỏ kèm hướng dẫn khôi phục tay.
post {
failure {
script {
def ok = sh(script: "ssh deploy@ops 'test -s ${env.BK}.sql'", returnStatus: true) == 0
if (env.db_updated == 'true' && ok) {
sh "ssh deploy@ops \"mysql --login-path=${env.DB_HOST} ${env.DB_DATABASE} < ${env.BK}.sql\""
} else if (env.db_updated == 'true') {
echo "KHÔNG CÓ BẢN BACKUP - cần khôi phục database bằng tay từ bản backup đêm"
}
}
}
cleanup {
sh "ssh deploy@ops 'rm -f ${env.BK}*.sql' || true"
}
}Lưới an toàn cuối cùng vẫn là bản backup database hằng đêm bằng mysqldump và rclone, nhưng khôi phục từ bản đêm qua là mất dữ liệu cả ngày.
5. Chuyển VPS và migrate: xoá máy cũ hay không
Chuyển VPS: xong thì dọn máy cũ
Pipeline chuyển VPS nhận các cặp tham số máy hiện tại và máy mới, bỏ qua stage không cần, và chặn ngay nếu phiên bản PHP của minion mới khác minion cũ. Thứ tự: copy code, dump và import database sang máy mới, sửa DB_HOST, tạo cấu hình trên balancer mới hoặc sửa IP minion trên balancer cũ, kiểm tra HTTP, rồi mới tới stage dọn dẹp máy cũ.
Rollback ở đây đơn giản: mọi thay đổi trước stage dọn dẹp đều diễn ra trên máy mới, nên chỉ cần gỡ những gì đã tạo ở máy mới, máy cũ vẫn phục vụ traffic. Nhưng một khi stage dọn dẹp đã chạy, không còn đường lui tự động. Vì vậy HTTP lỗi ở bước kiểm tra phải chặn stage dọn dẹp, hoặc tách dọn dẹp thành một bước bấm tay riêng.
Migrate: giữ máy cũ để rollback tức thì
Khi chuyển hàng loạt site (ví dụ dồn toàn bộ site của một minion sang máy mới), pipeline rsync code sang máy đích nhưng không xoá ở máy nguồn. Khi đổi balancer, nó copy cấu hình và chứng chỉ sang balancer mới, rồi biến file trên balancer cũ thành proxy chuyển tiếp, để site không gián đoạn trong lúc chờ chủ domain đổi bản ghi DNS:
# balancer cũ, trong thời gian chờ DNS
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://10.0.0.12:80; # balancer mới
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Trước khi ghi đè, pipeline chép file cấu hình gốc thành bản .bak có timestamp; có lỗi thì khối catch chép lại và reload nginx. Dọn máy cũ là việc làm tay, sau khi theo dõi đủ lâu.
6. Checklist thiết kế rollback
- Backup ngay trước thao tác, không dựa vào backup đêm.
- Cờ trạng thái đúng thời điểm: bước ghi bật cờ trước lệnh, bước tạo mới bật cờ sau lệnh.
- Hoàn tác theo thứ tự ngược, mỗi bước kiểm tra điều kiện trước khi chạy.
- Biết thứ tự thực thi của công cụ: dọn file tạm ở
cleanup, không ởalways. || truelà con dao hai lưỡi: giữ rollback chạy tiếp nhưng che mất lỗi; bước quan trọng như restore database phải báo lỗi rõ ràng.- Đặt điểm không quay lại càng muộn càng tốt, hoặc tách thành bước làm tay.
- Pipeline chạy hàng loạt phải chạy lại được: kiểm tra "đã làm chưa", tổng kết số site cập nhật, bỏ qua và lỗi ở cuối build.
Pipeline không làm thao tác rủi ro an toàn tuyệt đối, nhưng khi hỏng thì hỏng theo cách đoán trước được, kèm đường rollback đã nghĩ sẵn.
Bài tiếp theo
Laravel Octane và FrankenPHP: chạy Laravel như một app server. Octane khác PHP-FPM ở đâu, vì sao nó nhanh hơn, và những thay đổi trong cách viết code mà một site Laravel phải chấp nhận khi ứng dụng không còn "chết đi sống lại" sau mỗi request.
