Một đội vận hành nhỏ không thể ngồi canh hàng chục Cloud VPS cả ngày. Nginx hoặc PHP-FPM chết lúc nửa đêm, một queue worker rơi vào trạng thái FATAL, một ổ đĩa đầy dần: những sự cố này lặp lại đều đặn và cách xử lý gần như luôn giống nhau. Tài liệu này hướng dẫn dựng một lớp self-healing kiểu tiết kiệm: vài job định kỳ trên Jenkins, SSH vào từng máy, kiểm tra trạng thái dịch vụ, tự khởi động lại phần đã chết và báo kết quả lên Slack. Nội dung gồm thiết kế job, script mẫu có giới hạn an toàn, và quan trọng nhất là cách tránh để việc tự chữa che mất nguyên nhân gốc.

1. Self-healing là gì, và vì sao làm kiểu tiết kiệm

Self-healing là khả năng hệ thống tự phát hiện trạng thái hỏng và tự đưa nó về trạng thái đúng, không cần người can thiệp. Trong hệ sinh thái lớn, việc này do orchestrator đảm nhận, ví dụ Kubernetes tự dựng lại container khi probe thất bại. Cụm Cloud VPS thì không có orchestrator: mỗi dịch vụ là một unit systemd hoặc một chương trình supervisor trên một máy cụ thể. Phần self-healing phải tự dựng bằng ba thành phần:

  1. Phát hiện: một job chạy theo lịch, hỏi trạng thái từng dịch vụ.
  2. Hành động: khởi động lại đúng dịch vụ đang hỏng.
  3. Báo cáo: gửi Slack để con người biết chuyện đã xảy ra và quyết định có điều tra tiếp hay không.

Gọi là "tiết kiệm" vì không thêm hệ thống mới: mọi thứ chạy trên Jenkins có sẵn, dùng đúng credential SSH trong bài về Jenkins làm control plane, không cần cài agent lên từng máy.

2. Ba loại job self-healing trên Jenkins

Loại jobChu kỳ gợi ýKiểm traHành động
Quét dịch vụ web5–15 phútMinion: nginx và php<phiên bản>-fpm có active không. Balancer: nginx. Đọc phần log mới của PHP-FPM và nginxRestart dịch vụ không active (có giới hạn). Slack khi log có ERROR, FATAL, crit, emerg
Quét supervisor10 phútsupervisorctl status: worker ở STOPPED, FAILED, FATALqueue:restart, sửa quyền thư mục, supervisorctl start, kiểm tra lại. Slack kể cả khi thành công
Quét đĩaMỗi đêm hoặc mỗi giờDung lượng trống các phân vùng trên mọi máyKhông tự sửa. Dưới ngưỡng thì Slack kèm thư mục lớn nhất

Chu kỳ nên viết theo ký hiệu H/15, H/10 của Jenkins: H băm tên job ra một phút lệch cố định, nên các job quét không cùng đụng vào các máy tại một thời điểm. Mọi job quét nên bật disableConcurrentBuilds: lượt trước chưa xong thì lượt sau xếp hàng, không chạy chồng. Lệnh systemctl is-active trả về chuỗi trạng thái và mã thoát khác 0 nếu dịch vụ không chạy, rất hợp với script.

Quét dịch vụ trên một minion

// trích một stage pipeline quét dịch vụ
def phpVersion = sh(
    script: "ssh ${SSH_OPTS} deploy@${minion} \"php -v | head -n1 | cut -d' ' -f2 | cut -d'.' -f1,2\"",
    returnStdout: true).trim()
def fpmService = "php${phpVersion}-fpm"

def nginxStatus = sh(
    script: "ssh ${SSH_OPTS} deploy@${minion} 'systemctl is-active nginx 2>/dev/null || echo inactive'",
    returnStdout: true).trim()

if (nginxStatus != 'active') {
    sh "ssh ${SSH_OPTS} deploy@${minion} 'sudo /usr/local/sbin/heal-service.sh nginx'"
}

Hai chi tiết đáng chú ý. Thứ nhất, phiên bản PHP được dò trực tiếp từ php -v, nên một job phục vụ được mọi nhóm minion chạy PHP 7.2, 7.4 hay 8.x mà không phải khai báo tên service cho từng máy (xem bài về chạy nhiều phiên bản PHP). Lưu ý php trơn trỏ theo update-alternatives; nếu máy có nhiều phiên bản, hãy lấy danh sách unit php*-fpm thay vì dò CLI. Thứ hai, || echo inactive biến lỗi của chính lệnh kiểm tra thành một giá trị xác định, thay vì để pipeline đỏ.

Đọc log mới mà không đọc lại log cũ

Mỗi lần quét, job lấy phần cuối log PHP-FPM và nginx, chỉ giữ dòng có thời gian mới hơn mốc đã lưu ở lần trước (ghi vào một file nhỏ trên chính máy đó), bỏ dòng mức notice. Nhờ vậy Slack chỉ nhận lỗi mới, không lặp một cảnh báo cũ mỗi chu kỳ. Giới hạn cần biết: nếu số dòng lấy về quá ít so với lượng log sinh ra trong một chu kỳ, các dòng trước đó bị bỏ qua.

Self-healing cho supervisor: kiểm tra tên trước khi hành động

Job supervisor thường tách tên domain từ tên chương trình theo quy ước <domain>-worker. Trước khi chạy php artisan queue:restart và supervisorctl start, phải kiểm tra domain chỉ chứa chữ, số, dấu chấm, gạch ngang và thư mục site có tồn tại. Hành động tự chữa dựa trên chuỗi lấy từ output của máy khác luôn phải qua bước kiểm tra như vậy, nếu không một tên chương trình lạ có thể trở thành lệnh chạy bằng quyền cao.

3. Viết script self-healing an toàn

Self-healing bằng restart "trần" (không kiểm tra cấu hình, không giới hạn số lần) là cách làm nguy hiểm. Khung tối thiểu cho một script self-healing chạy trên máy đích:

#!/bin/bash
# /usr/local/sbin/heal-service.sh <service>: gọi từ Jenkins qua SSH
set -u
SERVICE=$1
STATE=/var/lib/heal/${SERVICE}.restarts
MAX_RESTARTS=3          # tối đa 3 lần
WINDOW=3600             # trong 1 giờ
mkdir -p /var/lib/heal

state=$(systemctl is-active "$SERVICE" 2>/dev/null || true)
[ "$state" = "active" ] && exit 0                 # khoẻ thì thôi

now=$(date +%s)
recent=$(awk -v n="$now" -v w="$WINDOW" 'n-$1<w' "$STATE" 2>/dev/null | wc -l)
if [ "$recent" -ge "$MAX_RESTARTS" ]; then
    echo "GIVE_UP: $SERVICE đã restart $recent lần trong cửa sổ, cần người xử lý"
    exit 2
fi

# nginx: kiểm tra cấu hình TRƯỚC khi restart
if [ "$SERVICE" = "nginx" ] && ! nginx -t 2>/tmp/nginx-test.err; then
    echo "CONFIG_BROKEN: nginx -t lỗi, KHÔNG restart"; cat /tmp/nginx-test.err
    exit 3
fi

echo "$now" >> "$STATE"
systemctl restart "$SERVICE"
sleep 3
systemctl is-active --quiet "$SERVICE" && echo "RESTARTED: $SERVICE" || { echo "STILL_DOWN: $SERVICE"; exit 4; }

Mỗi phần của script trả lời một câu hỏi cụ thể:

  • nginx -t trước khi restart: nginx chết vì file cấu hình sai thì restart chỉ thất bại thêm lần nữa. Theo tài liệu điều khiển nginx, kiểm tra cú pháp là bước bắt buộc trước mọi thay đổi.
  • Giới hạn số lần: một dịch vụ restart nhiều lần trong một giờ không phải sự cố ngẫu nhiên, nó cần con người. Script phải biết "bỏ cuộc" và kêu to.
  • Kiểm tra lại sau restart: systemctl restart thành công không có nghĩa dịch vụ còn sống sau vài giây. Với PHP-FPM, nên kiểm tra thêm socket hoặc một request vào trang health check.
  • Mã thoát khác nhau: để Jenkins phân biệt đã chữa được, bỏ cuộc hay cấu hình hỏng, và gắn màu Slack tương ứng.

Với supervisor, tự khởi động lại được cấu hình ngay trong file chương trình. Theo tài liệu cấu hình supervisor, autorestart và startretries quyết định số lần supervisor tự thử lại trước khi để chương trình ở FATAL. Job quét định kỳ chỉ là lớp self-healing thứ hai, bắt những worker mà supervisor đã bỏ cuộc.

4. Rủi ro lớn nhất của self-healing: che giấu lỗi gốc

Self-healing giải quyết triệu chứng, không giải quyết nguyên nhân. Nó có thể làm một vấn đề tồn tại rất lâu mà không ai để ý:

Cái bị cheBiểu hiệnCách giữ lại tín hiệu
Dịch vụ bị OOM kill rồi được restartSite gián đoạn vài phút, không ai biết vì saoSlack mỗi lần restart, kèm journalctl -k | grep -i "out of memory"
PHP-FPM cạn max_childrenRestart làm hết nghẽn tạm thời, vài giờ sau nghẽn lạiĐếm số lần restart theo ngày, theo máy. Tăng đột biến là tín hiệu
Cấu hình sai sau một lần sửaDịch vụ chết lặp lạinginx -t trước restart, báo CONFIG_BROKEN
Máy hỏng thật (đĩa, mạng, nhân)SSH timeout, job báo lỗi quétPhân biệt "dịch vụ chết" với "không SSH được máy". Cái sau cần người
Rò rỉ bộ nhớ trong code ứng dụngRestart đều đặn, lỗi biến mất rồi quay lạiBiểu đồ RAM theo thời gian, không chỉ đếm restart

Ví dụ điển hình là job restart PHP-FPM định kỳ vài giờ một lần. Khi mọi site trên minion dùng chung một pool, một site nặng có thể chiếm hết worker và các site khác nghẽn theo; restart định kỳ xoá sạch worker đang kẹt nên mọi thứ chạy lại. Nhưng đó chỉ là giải pháp tạm: nguyên nhân (giới hạn worker dùng chung, request quá chậm) vẫn còn nguyên. Hướng xử lý gốc là tách pool PHP-FPM theo site và tìm request chậm, sẽ được bàn ở một bài sau.

Quy tắc đơn giản để tránh bẫy này: mỗi lần self-healing hành động phải để lại bằng chứng. Slack có tên máy, tên dịch vụ, trạng thái trước, kết quả sau. Số lần restart theo thời gian phải được nhìn thấy, vì "restart hàng chục lần một tuần" khác hẳn "restart một lần".

5. Giới hạn và checklist self-healing

Giới hạn cần biết của mô hình self-healing quét định kỳ:

  • Chu kỳ quét là thời gian gián đoạn tối đa. Dịch vụ không chịu nổi khoảng đó cần thêm kiểm tra bên ngoài (uptime) hoặc để systemd tự restart bằng Restart=on-failure.
  • Jenkins là điểm lỗi đơn: Jenkins chết thì lớp self-healing cũng mất. Cảnh báo khi job quét không chạy trong một khoảng thời gian.
  • Quét tuần tự từng máy: một máy treo SSH làm chậm cả lượt quét. ConnectTimeout ngắn là bắt buộc.
  • Cảnh báo đĩa theo ngưỡng tĩnh không đo tốc độ đầy; một máy sắp đầy trong vài ngày chỉ được báo ở ngày cuối.

Checklist cho một job self-healing:

  • Kiểm tra cấu hình trước khi restart; giới hạn số lần trong cửa sổ thời gian; kiểm tra lại sau restart.
  • Mọi chuỗi lấy từ máy khác được kiểm tra trước khi đưa vào lệnh.
  • Mỗi hành động gửi một thông báo có đủ ngữ cảnh; gom theo lượt quét để Slack không thành ồn.
  • Đếm và vẽ số lần self-healing hành động theo máy, theo dịch vụ.
  • Mỗi lần restart lặp lại phải dẫn tới một việc điều tra nguyên nhân gốc.

Self-healing mua thời gian, không mua lời giải. Cùng tư duy "nhờ máy làm việc lặp lại" như bài backup database hằng đêm: tự động hoá càng nhiều thì càng phải có tín hiệu cho biết nó đang chạy đúng.

Bài tiếp theo

Domain demo và bài học bị Google index. Khi các site chạy thử trên domain demo bị Google lập chỉ mục, và cách chặn từ phía nginx.