Một máy chủ nginx đơn lẻ sập là cả website sập theo. Dựng thêm máy dự phòng thì dễ, cái khó là giữ cho các máy thật sự giống nhau: cùng cấu hình, cùng dữ liệu, và máy dự phòng lên thay trong vài giây mà không ai phải thức dậy lúc 3 giờ sáng. Bài này dựng một cụm bốn node Active–Standby trên Ubuntu 24.04 bằng keepalived, đồng bộ cấu hình qua git và dữ liệu qua lsyncd, kèm những cái bẫy mất dữ liệu mà hướng dẫn thông thường hay bỏ qua.
Mô hình
Nguoi dung / DNS
|
VIP 192.168.1.10 (keepalived)
|
+--------------------+-------------------+
| | |
web1 .11 ACTIVE web2 .12 standby web3 .13, web4 .14 standby
(priority 110) (100) (90, 80)
|
+-- lsyncd day /var/www --> web2, web3, web4
git push all main --> repo cau hinh tren ca 4 node
Bốn máy nằm chung một LAN. Người dùng chỉ biết địa chỉ ảo VIP 192.168.1.10. Tại mỗi thời điểm chỉ một node giữ VIP và phục vụ web; ba node còn lại chạy nginx sẵn, chờ tiếp quản. Có ba bài toán tách biệt:
- Chuyển VIP khi node Active hỏng: keepalived (giao thức VRRP).
- Đồng bộ cấu hình
/etc/nginx: git, có kiểm tra cú pháp và tự hoàn tác. - Đồng bộ dữ liệu
/var/www: lsyncd, chỉ chạy trên node đang giữ VIP.
Cài gói trên cả bốn máy:
sudo apt install -y nginx keepalived lsyncd rsync git curl
sudo systemctl disable --now lsyncd # lsyncd se do keepalived dieu khien
Các script đồng bộ chạy bằng root và SSH qua lại giữa các node, nên mỗi node cần một khoá riêng của root, và khoá công khai của nó phải nằm trong /root/.ssh/authorized_keys của ba node kia. Sau đó chạy ssh-keyscan để nạp host key vào known_hosts. Mọi lệnh ssh trong script đều dùng BatchMode=yes, để khi thiếu khoá thì báo lỗi ngay thay vì treo chờ nhập mật khẩu.
Bước 1: keepalived và VIP
Cả bốn node dùng chung một file cấu hình, chỉ khác giá trị priority (110, 100, 90, 80):
global_defs {
enable_script_security
script_user root
max_auto_priority
}
vrrp_script chk_nginx {
script "/usr/local/bin/check_nginx.sh"
interval 2
timeout 3
fall 2
rise 2
}
vrrp_instance VI_NGINX {
state BACKUP
interface ens33
virtual_router_id 81
priority 110
nopreempt
advert_int 1
authentication {
auth_type PASS
auth_pass DoiMatKhau
}
virtual_ipaddress {
192.168.1.10/24
}
track_script {
chk_nginx
}
notify_master "/usr/local/bin/www-sync-role.sh master"
notify_backup "/usr/local/bin/www-sync-role.sh backup"
notify_fault "/usr/local/bin/www-sync-role.sh fault"
notify_stop "/usr/local/bin/www-sync-role.sh stop"
}
Có ba điểm đáng chú ý trong cấu hình này:
- Script kiểm tra phải hỏi HTTP thật, không chỉ xem tiến trình còn sống. Tiến trình nginx còn chạy mà treo không trả lời thì node vẫn phải nhả VIP.
state BACKUPtrên mọi node, kèmnopreempt: node vừa hồi phục không giành lại VIP. Không có dòng này, mỗi lần máy chính khởi động lại, người dùng sẽ bị rớt kết nối hai lần thay vì một.- Các dòng
notify_*là chỗ gắn phần đồng bộ dữ liệu ở bước 3.
Script kiểm tra /usr/local/bin/check_nginx.sh:
#!/bin/bash
# Khong chi kiem tra tien trinh song: phai thuc su tra loi HTTP thi moi giu VIP
curl -sf -m 2 -o /dev/null http://127.0.0.1/ || exit 1
Với interval 2 và fall 2, VIP chuyển sang node khác sau khoảng 3–4 giây kể từ khi nginx trên node Active ngừng trả lời.
Bước 2: đồng bộ cấu hình bằng git
Đừng đồng bộ /etc/nginx bằng rsync thô. Chỉ một file cấu hình sai là cả bốn node cùng hỏng trong một giây, và bạn mất luôn máy dự phòng. Thay vào đó, mỗi node giữ một bare repo /srv/git/nginx.git để nhận push và một bản làm việc /srv/nginx-config. Thư mục nginx/ trong repo tương ứng với /etc/nginx.
Remote all trên mỗi node có bốn địa chỉ push, gồm chính nó và ba node còn lại (ví dụ dưới đây là trên web1):
cd /srv/nginx-config
git remote add all /srv/git/nginx.git
git remote set-url --add --push all /srv/git/nginx.git
git remote set-url --add --push all ssh://[email protected]/srv/git/nginx.git
git remote set-url --add --push all ssh://[email protected]/srv/git/nginx.git
git remote set-url --add --push all ssh://[email protected]/srv/git/nginx.git
Hook /srv/git/nginx.git/hooks/post-receive cập nhật bản làm việc rồi gọi script triển khai:
#!/bin/bash
# Khi git chay hook, no dat san GIT_DIR tro vao bare repo -> phai bo di
unset GIT_DIR GIT_WORK_TREE GIT_QUARANTINE_PATH
set -e
cd /srv/nginx-config
git fetch -q origin main
git reset -q --hard origin/main
if /usr/local/bin/nginx-deploy.sh; then
echo " [$(hostname)] da trien khai $(git rev-parse --short HEAD) va reload nginx"
else
echo " [$(hostname)] TRIEN KHAI THAT BAI - da hoan tac, xem /var/log/nginx-sync.log"
exit 1
fi
Script triển khai /usr/local/bin/nginx-deploy.sh có một nguyên tắc: không bao giờ để lại cấu hình hỏng trên đĩa. Nó sao lưu, chép cấu hình mới, chạy nginx -t; nếu sai cú pháp thì chép bản cũ trở lại:
#!/bin/bash
# Dua noi dung repo /srv/nginx-config vao vi tri that.
# Nguyen tac: cau hinh sai thi HOAN TAC, khong bao gio de lai cau hinh hong tren dia.
# Du lieu web (/var/www) KHONG di qua repo nua: do lsyncd dong bo (xem www-sync-role.sh).
set -euo pipefail
REPO=/srv/nginx-config
LOG=/var/log/nginx-sync.log
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }
[ -d "$REPO/nginx" ] || { log "ERROR: thieu $REPO/nginx, bo qua"; exit 1; }
BACKUP=$(mktemp -d)
trap 'rm -rf "$BACKUP"' EXIT
rsync -a /etc/nginx/ "$BACKUP/nginx/"
# Chep chung chi TRUOC, vi vhost co the tham chieu toi chung.
# Khong dung --delete o day: tranh xoa nham cert certbot vua cap ma chua kip commit.
[ -d "$REPO/letsencrypt" ] && rsync -a "$REPO/letsencrypt/" /etc/letsencrypt/
# /etc/nginx do repo quyet dinh hoan toan nen dung --delete
rsync -a --delete "$REPO/nginx/" /etc/nginx/
if nginx -t >>"$LOG" 2>&1; then
systemctl reload nginx
log "OK: da trien khai $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null) va reload nginx"
else
rsync -a --delete "$BACKUP/nginx/" /etc/nginx/
log "ERROR: cau hinh moi SAI CU PHAP -> da hoan tac ve ban cu, nginx van chay binh thuong"
exit 1
fi
Từ đây, sửa cấu hình trên node nào cũng được:
cd /srv/nginx-config
vi nginx/sites-available/shop.conf
ln -s ../sites-available/shop.conf nginx/sites-enabled/shop.conf
git add -A && git commit -m "them site shop"
git push all main
# remote: [web1] da trien khai 2a94c1e va reload nginx
# remote: [web2] da trien khai 2a94c1e va reload nginx
# ...
Còn một câu hỏi: node đang tắt lúc bạn push thì sao? Nó bị bỏ lỡ lần push đó. Để khắc phục, một systemd timer chạy mỗi 2 phút (và 60 giây sau khi khởi động). Timer hỏi các peer xem ai có commit mới hơn, lấy về bản mới nhất rồi triển khai. Nếu lịch sử hai bên đã rẽ nhánh, timer không tự gộp mà ghi cảnh báo để người quản trị xử lý:
#!/bin/bash
# Keo cau hinh moi nhat tu cac node khac ve. Chay dinh ky (timer 2 phut) de node
# vua bat lai tu bat kip. Chon ban "di xa nhat" trong so cac peer dang song.
set -euo pipefail
LOG=/var/log/nginx-sync.log
cd /srv/nginx-config
LOCAL=$(git rev-parse HEAD)
BEST=$LOCAL; BEST_URL=""
for URL in $(git remote get-url --push --all all | grep ssh://); do
REMOTE=$(timeout 15 git ls-remote "$URL" main 2>/dev/null | awk '{print $1}') || continue
[ -z "$REMOTE" ] || [ "$REMOTE" = "$BEST" ] && continue
git cat-file -e "$REMOTE" 2>/dev/null || timeout 30 git fetch -q "$URL" main || continue
if git merge-base --is-ancestor "$BEST" "$REMOTE"; then
BEST=$REMOTE; BEST_URL=$URL # peer di truoc -> ung vien moi
elif ! git merge-base --is-ancestor "$REMOTE" "$BEST"; then
# Hai ben co commit rieng: khong tu gop, bao de nguoi van hanh xu ly
echo "$(date '+%F %T') WARN: lich su RE NHANH voi $URL ($(git rev-parse --short "$REMOTE")) - can xu ly tay" >> "$LOG"
fi
done
[ "$BEST" = "$LOCAL" ] && exit 0
git reset -q --hard "$BEST"
git push -q origin +main
/usr/local/bin/nginx-deploy.sh
echo "$(date '+%F %T') da bat kip cau hinh tu $BEST_URL -> $(git rev-parse --short HEAD)" >> "$LOG"
# /etc/systemd/system/nginx-config-sync.timer
[Timer]
OnBootSec=60
OnUnitActiveSec=2min
[Install]
WantedBy=timers.target
Bước 3: đồng bộ dữ liệu bằng lsyncd
Dữ liệu web như ảnh tải lên hay file do ứng dụng sinh ra thay đổi liên tục và không nên nằm trong git. lsyncd theo dõi /var/www bằng inotify và gọi rsync sau khoảng 1 giây, kể cả khi file bị xoá. Chỉ node giữ VIP được chạy lsyncd: nếu hai node cùng đẩy với --delete, chúng sẽ xoá dữ liệu của nhau.
Mẫu cấu hình /usr/local/lib/nginx-cluster/www-sync.lua.tpl. Chỗ __PEERS__ được điền danh sách đích lúc chạy:
-- Sinh tu dong boi www-sync-role.sh / www-reconcile.sh vao /run/nginx-cluster/www-sync.lua. KHONG sua file trong /run.
settings {
logfile = "/var/log/lsyncd/www-sync.log",
statusFile = "/var/log/lsyncd/www-sync.status",
insist = true, -- peer dang tat luc khoi dong van chay tiep, thu lai sau
maxProcesses = 4,
}
for _, host in ipairs({ __PEERS__ }) do
sync {
default.rsync,
source = "/var/www/",
target = "root@" .. host .. ":/var/www/",
delete = true,
delay = 1,
rsync = {
archive = true,
rsh = "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5",
_extra = { "--timeout=30" },
},
}
end
Unit systemd www-sync.service không được enable; chỉ keepalived mới bật và tắt nó:
[Unit]
Description=lsyncd day /var/www sang cac node standby (chi chay tren node giu VIP)
After=network-online.target
[Service]
ExecStart=/usr/bin/lsyncd -nodaemon /run/nginx-cluster/www-sync.lua
Restart=on-failure
RestartSec=5
# lsyncd thoat voi ma 143 khi nhan SIGTERM - do la dung binh thuong
SuccessExitStatus=143
SuccessExitStatus=143 là cần thiết, vì lsyncd thoát với mã 143 khi nhận SIGTERM. Thiếu dòng này, mỗi lần node nhả VIP, systemd lại đánh dấu service là failed, và hệ thống giám sát báo động giả.
Cái bẫy: node cũ lên làm chủ rồi xoá dữ liệu mới
Cách làm phổ biến là notify_master gọi thẳng systemctl start lsyncd. Cách này có một kịch bản mất dữ liệu rất thật:
- web1 tắt để bảo trì. web2 lên Active và nhận thêm ảnh tải lên.
- Cả phòng máy mất điện.
- Có điện lại, web1 khởi động nhanh hơn, và vì có priority cao nhất nên lên Active trước.
- lsyncd trên web1 đẩy bản cũ của mình sang web2 với
--delete. Ảnh mới trên web2 biến mất.
Cách chặn gồm hai quy tắc:
- Node vừa lên Active phải kéo bù trước:
rsync -autừ từng peer, chỉ lấy file mới hơn và không xoá gì. - Node Active chỉ đẩy có xoá sang những peer đã kéo bù thành công. Peer chưa liên lạc được thì để nguyên, chờ lượt sau.
Hàm dùng chung /usr/local/lib/nginx-cluster/common.sh (danh sách node đặt trong /etc/nginx-cluster/cluster.env):
# Ham dung chung, duoc source boi cac script dong bo
. /etc/nginx-cluster/cluster.env
SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=5"
# IP LAN cua chinh may nay (la 1 trong NODES)
self_ip() { for ip in $NODES; do ip -4 -o addr show | grep -qw "inet $ip" && { echo "$ip"; return; }; done; }
peers() { local me; me=$(self_ip); for ip in $NODES; do if [ "$ip" != "$me" ]; then echo "$ip"; fi; done; }
has_vip() { ip -4 -o addr show | grep -qw "inet $VIP"; }
# Danh sach peer ma node MASTER da keo bu thanh cong -> chi nhung peer nay moi duoc
# nhan du lieu co --delete. Nam trong /run nen reboot la mat, bat buoc keo bu lai.
TARGETS=/run/nginx-cluster/www-targets
LSYNCD_CONF=/run/nginx-cluster/www-sync.lua
pull_from() { # keo file MOI HON tu peer ve, khong xoa gi
timeout 600 rsync -au -e "ssh $SSH_OPTS" "root@$1:/var/www/" /var/www/
}
write_lsyncd_conf() { # sinh cau hinh lsyncd tu danh sach trong $TARGETS
local list; list=$(sed 's/.*/"&"/' "$TARGETS" | paste -sd, | sed 's/,/, /g')
sed "s/__PEERS__/$list/" /usr/local/lib/nginx-cluster/www-sync.lua.tpl > "$LSYNCD_CONF.tmp"
mv "$LSYNCD_CONF.tmp" "$LSYNCD_CONF"
}
Lưu ý hàm peers() dùng if thay vì [ ... ] && echo. Với cách viết thứ hai, khi node hiện tại nằm cuối danh sách, lệnh cuối cùng của hàm trả về false. Script nào gọi hàm này dưới set -e -o pipefail sẽ dừng giữa chừng mà không báo gì. Lỗi này chỉ xảy ra trên đúng một node, nên rất khó tìm.
Script keepalived gọi mỗi khi node đổi vai trò, /usr/local/bin/www-sync-role.sh:
#!/bin/bash
# keepalived goi script nay moi khi node doi vai tro:
# master -> keo cac file MOI HON tu tung peer ve (khong xoa); peer nao keo duoc
# thi moi dua vao danh sach dich cua lsyncd, roi bat lsyncd
# backup|fault|stop -> tat lsyncd (chi node giu VIP moi duoc day du lieu)
# Peer vang mat luc nay se duoc www-reconcile.sh keo bu roi them vao sau.
# Goi tay: www-sync-role.sh master
. /usr/local/lib/nginx-cluster/common.sh
LOG=/var/log/nginx-sync.log
log() { echo "$(date '+%F %T') [www] $*" >> "$LOG"; }
ROLE=$(echo "${3:-$1}" | tr 'A-Z' 'a-z')
mkdir -p /run/nginx-cluster
exec 9>/run/www-sync-role.lock; flock 9
case "$ROLE" in
master)
log "tro thanh MASTER -> keo bu du lieu tu cac peer truoc khi day"
: > "$TARGETS"
for p in $(peers); do
if pull_from "$p" 2>>"$LOG"; then echo "$p" >> "$TARGETS"; log " da keo bu tu $p"
else log " CHUA keo bu duoc tu $p -> tam thoi KHONG day sang $p"; fi
done
# Neu trong luc keo du lieu da mat VIP thi thoi, khong bat lsyncd
if ! has_vip; then log "da mat VIP, khong bat lsyncd"; exit 0; fi
write_lsyncd_conf
systemctl restart www-sync && log "da bat lsyncd, dich: $(paste -sd' ' "$TARGETS")"
;;
backup|fault|stop)
rm -f "$TARGETS"
systemctl is-active -q www-sync && { systemctl stop www-sync; log "chuyen $ROLE -> da tat lsyncd"; }
;;
esac
exit 0
Danh sách đích nằm trong /run, là tmpfs, nên bị xoá khi reboot. Nhờ vậy, sau mỗi lần khởi động, node luôn phải kéo bù lại từ đầu.
Lưới an toàn mỗi 5 phút
lsyncd có thể bỏ lỡ sự kiện (khi vượt giới hạn inotify, hay khi peer tắt quá lâu), và notify của keepalived cũng có lúc không chạy. Vì vậy thêm một timer đối soát chạy trên cả bốn node:
- Trên node giữ VIP: kéo bù từ những peer còn thiếu rồi thêm chúng vào danh sách đích, rsync đầy đủ sang các peer đã có trong danh sách, và bật lsyncd nếu nó chưa chạy.
- Trên node không giữ VIP: tắt lsyncd nếu nó đang chạy.
#!/bin/bash
# Luoi an toan chay moi 5 phut (www-reconcile.timer):
# - Node giu VIP:
# * peer chua keo bu (vang mat luc chuyen vai tro): keo bu truoc, thanh cong moi them
# vao danh sach dich va khoi dong lai lsyncd -> khong bao gio xoa du lieu moi hon cua peer
# * peer da keo bu: rsync day du (co --delete) de peer vua bat lai / lo su kien van hoi tu
# * lsyncd chua chay thi bat
# - Node khong giu VIP: dam bao lsyncd KHONG chay.
. /usr/local/lib/nginx-cluster/common.sh
LOG=/var/log/nginx-sync.log
log() { echo "$(date '+%F %T') [www] $*" >> "$LOG"; }
exec 9>/run/www-sync-role.lock; flock -n 9 || exit 0 # dang doi vai tro thi de lan sau
if ! has_vip; then
rm -f "$TARGETS"
systemctl is-active -q www-sync && { systemctl stop www-sync; log "reconcile: lsyncd dang chay tren node khong giu VIP -> da tat"; }
exit 0
fi
mkdir -p /run/nginx-cluster
if [ ! -f "$TARGETS" ]; then
# Giu VIP ma chua tung chay vai tro master (vd notify bi lo): lam lai tu dau
flock -u 9; exec /usr/local/bin/www-sync-role.sh master
fi
CHANGED=0
for p in $(peers); do
if ! grep -qx "$p" "$TARGETS"; then
if pull_from "$p" 2>/dev/null; then
echo "$p" >> "$TARGETS"; CHANGED=1; log "reconcile: da keo bu tu $p -> them vao danh sach dich"
fi
continue
fi
OUT=$(timeout 900 rsync -a --delete --itemize-changes -e "ssh $SSH_OPTS" /var/www/ "root@$p:/var/www/" 2>&1) \
|| { log "reconcile: khong day duoc sang $p"; continue; }
[ -n "$OUT" ] && log "reconcile: da sua lech $(echo "$OUT" | wc -l) muc tren $p"
done
if [ "$CHANGED" = 1 ] || ! systemctl is-active -q www-sync; then
write_lsyncd_conf
systemctl restart www-sync && log "reconcile: (khoi dong lai) lsyncd, dich: $(paste -sd' ' "$TARGETS")"
fi
exit 0
# /etc/systemd/system/www-reconcile.timer
[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
Cái giá phải trả: file bị xoá trong lúc cụm bị chia cắt có thể “sống lại” sau bước kéo bù. Đây là một lựa chọn có chủ đích. Một file thừa thì xoá lại mất vài giây, còn ảnh của khách hàng đã mất thì không lấy lại được.
Kiểm thử trước khi tin nó
Một cụm HA chưa từng được rút phích thì chưa phải là HA. Dưới đây là các kịch bản nên chạy thật, kèm kết quả đo được trên cụm bốn node trong bài:
| Kịch bản | Kết quả mong đợi |
|---|---|
| Ghi rồi xoá một file trên node Active | Có mặt và biến mất trên ba peer trong ≤ 4 giây |
systemctl stop nginx trên node Active trong khi một máy khác gọi curl vào VIP mỗi 0,5 giây |
Khoảng 3,5 giây lỗi, sau đó node kế tiếp trả lời; node cũ bật lại không giành VIP |
| Cô lập một node, trong lúc đó đổi dữ liệu và push cấu hình | Khi nối lại, node tự bắt kịp dữ liệu sau ~30 giây và cấu hình sau ≤ 2 phút |
| Reboot một node Standby | nginx, keepalived và hai timer tự chạy; cấu hình và dữ liệu khớp |
| Node Standby giữ một file mới và bị cô lập đúng lúc VIP chuyển | File không bị xoá; khi nối lại, file được kéo về và phát tán ra cả cụm |
| Push một cấu hình sai cú pháp | Cả bốn node báo thất bại, tự hoàn tác, và vẫn phục vụ bình thường |
Để kiểm tra nhanh trạng thái cả cụm, chạy lệnh sau trên node bất kỳ:
for i in 11 12 13 14; do ssh [email protected].$i '
echo "$(hostname) vip=$(ip -4 -o a show ens33 | grep -c 192.168.1.10) \
lsyncd=$(systemctl is-active www-sync) cfg=$(git -C /srv/nginx-config rev-parse --short HEAD) \
www=$(cd /var/www && find . -type f | sort | xargs md5sum | md5sum | cut -c1-8)"'; done
Cụm khoẻ khi đúng một dòng có vip=1 lsyncd=active, còn cả bốn dòng có cùng giá trị cfg và www.
Những điều cần nhớ khi vận hành
- Không sửa trực tiếp trong
/etc/nginx. Lần triển khai sau sẽ ghi đè thay đổi đó. Mọi thay đổi phải đi qua git. - Chỉ ghi dữ liệu vào node đang giữ VIP. Cách dễ nhất là SSH hay SCP thẳng vào địa chỉ VIP. Cảnh báo host key thay đổi sau mỗi lần failover là bình thường.
- Muốn chuyển VIP có chủ đích để bảo trì, chạy
systemctl stop keepalivedtrên node Active. Khi bật lại, node đó sẽ ở vai trò Standby. - Không đưa keepalived.conf vào repo chung, vì mỗi node có priority riêng. Hãy giữ một bản chuẩn của từng node để so khớp.
- Nếu thấy hai node cùng giữ VIP, gần như chắc chắn là gói VRRP multicast bị chặn giữa các node. Kiểm tra bằng
tcpdump -i ens33 vrrp.
Toàn bộ cơ chế chỉ dùng những công cụ có sẵn trong kho của Ubuntu và vài trăm dòng bash. Không cần shared storage, cũng không cần hệ file cluster. Việc tốn công nhất không phải là cài đặt mà là nghĩ trước xem chuyện gì xảy ra khi mất điện, và kiểm thử đúng kịch bản đó trước khi nó xảy ra thật.
