Bài 04
Automation: Script tự động dựng full LEMP + HA stack
Bài 04 — Automation: Script tự động dựng full LEMP + HA stack
Đến bài này, bạn đã:
- Bài 01-03: dựng được full stack manual — 6 server cấu hình tay, từng câu lệnh.
- Chặng 3 (Bash): biết viết script tự động hoá tử tế (function, error handling, strict mode).
Giờ là lúc kết hợp 2 cái: viết 1 script duy nhất chạy → dựng toàn bộ stack từ đầu đến cuối. Đây là cách DevOps thật sự làm việc — không ai cài tay 100 server một mỗi lần deploy.
Bài này là capstone của chặng. Không có syntax mới, không có concept mới. Chỉ là gắn tất cả lại với nhau một cách production-grade.
Vì sao cần automation?
Manual cài 1 stack mất khoảng 2 tiếng/server × 6 server = 12 tiếng. Bạn vừa làm xong, biết cảm giác đó.
Vấn đề thực tế:
- Khách thứ 2 cần stack tương tự → 12 tiếng nữa.
- Lỡ tay gõ sai 1 dòng → debug 2 tiếng tìm ra.
- Sau 6 tháng quay lại sửa → quên mất setup thế nào.
- Disaster recovery cần dựng lại → trong stress 2h sáng càng dễ sai.
Automation script giải quyết:
| Vấn đề | Automation |
|---|---|
| Lặp lại nhiều lần | Chạy 1 lệnh, vài phút xong |
| Sai do gõ tay | Code đã test, không sai random |
| Quên cấu hình | Script chính là documentation |
| Disaster recovery | Chạy lại trên server mới = same setup |
| Onboard team mới | Đọc script hiểu hệ thống |
Tâm thế đúng: Khi cài tay lần thứ 2, là lúc nên viết script. Lần thứ 3 mà vẫn cài tay = lãng phí.
Imperative vs Declarative — biết để chọn tool đúng
2 cách tiếp cận automation, hiểu sự khác biệt sẽ giúp chọn đúng tool:
Imperative — "làm theo bước"
Script Bash là imperative — bạn nói làm gì, theo thứ tự nào:
yum install nginx
systemctl start nginx
firewall-cmd --add-service=httpƯu: đơn giản, debug dễ, kiểm soát từng bước.
Nhược: viết lại logic đảm bảo idempotent thủ công (xem dưới).
Declarative — "muốn trạng thái gì"
Ansible, Terraform là declarative — bạn nói trạng thái mong muốn, tool tự lo:
# Ansible
- name: nginx is installed and running
package:
name: nginx
state: present
service:
name: nginx
state: startedƯu: idempotent built-in, code ngắn gọn.
Nhược: cần học DSL của tool, debug khó hơn.
Bài này dùng Bash
Vì:
- Bạn vừa học Bash xong.
- Đủ cho stack quy mô vừa (≤ 20 server).
- Không cần cài tool mới trên fresh server.
- Là bước đệm tốt trước khi chuyển sang Ansible.
Khi nào nên chuyển Ansible/Terraform: cuối bài sẽ nói.
Idempotency — chạy nhiều lần không vỡ
Idempotency = chạy script 1 lần hay 10 lần đều cho cùng kết quả, không gây side effect.
Ví dụ NON-idempotent:
# Chạy lần 1: user tạo OK
# Chạy lần 2: "User exists" error → script fail
useradd devops
echo "PATH=/opt/bin:$PATH" >> /etc/bashrc # Lần 2 sẽ append thêm 1 dòng nữa!Ví dụ idempotent:
# Chạy bao nhiêu lần cũng OK
id devops &>/dev/null || useradd devops
grep -q "PATH=/opt/bin" /etc/bashrc || echo "PATH=/opt/bin:$PATH" >> /etc/bashrcMọi script automation phải idempotent. Lý do: lúc fail giữa chừng (lỗi network, server hết disk, etc), bạn phải fix rồi rerun. Script không idempotent = rerun = đè hỏng thêm.
3 cách làm idempotent trong Bash:
- Check trước khi làm:
[[ -f file ]] || cp src file - Idempotent commands:
mkdir -p(OK nếu exists),dnf install -y(skip nếu cài rồi). - Pattern check-replace:
grep -q pattern file && sed -i ... || echo ... >> file.
Best practices cho shell automation
10 nguyên tắc bạn đã học ở chặng Bash, áp dụng ở đây:
Strict mode đầu mỗi script:
#!/bin/bash set -euo pipefailQuote biến luôn:
"$var", không$var.localtrong function, không leak biến.trap cleanup EXITđảm bảo cleanup chắc chắn chạy.Log với timestamp —
log_info(),log_error()thayecho.Exit code đúng nghĩa —
0= OK, khác 0 = lỗi cụ thể.Idempotent — script chạy lại không vỡ.
Variables tập trung 1 chỗ — config file, không hardcode trong code.
Functions ngắn, single purpose —
install_nginx(),configure_php_fpm().ShellCheck trước commit:
shellcheck deploy.sh
Thiết kế trước khi code
Cấu trúc thư mục
/opt/lemp-deploy/
├── deploy.sh # Main orchestrator
├── config.conf # Variables (IP, password, version)
├── inventory.conf # Danh sách server + role
├── README.md # Hướng dẫn
└── lib/
├── common.sh # Helper functions (log, check, retry)
├── 10-base.sh # CentOS base setup
├── 20-backend.sh # nginx + php-fpm + WordPress
├── 30-db.sh # MariaDB master + slave
├── 40-proxy.sh # nginx reverse proxy
├── 50-keepalived.sh # Keepalived VRRP
└── 90-verify.sh # Test & verify2 cách chạy script
Cách 1: Local trên mỗi server (đơn giản, đề xuất cho lab):
# SSH vào từng server, chạy:
./deploy.sh <role>
# Ví dụ:
./deploy.sh backend-1
./deploy.sh proxy-masterCách 2: Từ control node, SSH ra toàn bộ (production):
./deploy.sh all # SSH ra hết các server trong inventoryBài này dùng Cách 1 cho dễ hiểu. Cuối bài có gợi ý chuyển sang Cách 2.
Mô hình dependency
┌──────────────────┐
│ deploy.sh │ ← Entry point
└────────┬─────────┘
│
┌────────┴─────────┐
│ config.conf │ ← Read vars
│ common.sh │ ← Load helpers
└────────┬─────────┘
│ dispatch theo role
┌─────────┼─────────┬─────────┐
▼ ▼ ▼ ▼
10-base.sh 20-backend.sh 30-db.sh 40-proxy.sh
│
▼
50-keepalived.sh
│
▼
90-verify.shMọi role đều chạy 10-base.sh trước (setup OS), rồi mới đến module riêng.
Implementation
config.conf — variables tập trung
# /opt/lemp-deploy/config.conf
# ─── Network ───────────────────────────────────────
VIP="192.168.32.100"
PROXY_MASTER_IP="192.168.32.135"
PROXY_BACKUP_IP="192.168.32.136"
BACKEND_1_IP="192.168.32.133"
BACKEND_2_IP="192.168.32.134"
DB_MASTER_IP="192.168.32.137"
DB_SLAVE_IP="192.168.32.138"
# ─── Domain ────────────────────────────────────────
DOMAIN="test.com"
WEB_ROOT="/home/www/${DOMAIN}"
# ─── Versions ──────────────────────────────────────
PHP_VERSION="8.2"
WORDPRESS_URL="https://wordpress.org/latest.tar.gz"
# ─── Database ──────────────────────────────────────
DB_NAME="wordpress"
DB_USER="wordpress"
DB_PASSWORD="MyStrongPass123!"
DB_ROOT_PASSWORD="RootPass123!"
REPL_USER="repl"
REPL_PASSWORD="ReplPass123!"
# ─── Keepalived ────────────────────────────────────
KEEPALIVED_VRID=151
KEEPALIVED_AUTH_PASS="LempHA2024"
KEEPALIVED_INTERFACE="ens33"
# ─── SSL ───────────────────────────────────────────
SSL_DIR="/etc/nginx/ssl"
SSL_DAYS=365
# ─── Logging ───────────────────────────────────────
LOG_DIR="/var/log/lemp-deploy"
LOG_FILE="${LOG_DIR}/deploy-$(date +%Y%m%d-%H%M%S).log"⚠️ Production: đừng commit file này vào git khi có password. Dùng env vars hoặc vault (HashiCorp Vault, AWS Secrets Manager). Bài này simplify.
inventory.conf — danh sách server
# /opt/lemp-deploy/inventory.conf
# Format: <role>:<ip>:<hostname>
backend-1:192.168.32.133:backend-1.local
backend-2:192.168.32.134:backend-2.local
db-master:192.168.32.137:db-master.local
db-slave:192.168.32.138:db-slave.local
proxy-master:192.168.32.135:proxy-master.local
proxy-backup:192.168.32.136:proxy-backup.locallib/common.sh — helper functions
Đây là thư viện chia sẻ giữa các module:
#!/bin/bash
# /opt/lemp-deploy/lib/common.sh
# Helper functions dùng chung
# ─── Logging ───────────────────────────────────────────────────
log_info() {
local msg="$1"
local timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$timestamp] [INFO] $msg" | tee -a "$LOG_FILE"
}
log_warn() {
local msg="$1"
local timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$timestamp] [WARN] $msg" | tee -a "$LOG_FILE" >&2
}
log_error() {
local msg="$1"
local timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$timestamp] [ERROR] $msg" | tee -a "$LOG_FILE" >&2
}
# Die với error message + exit code
die() {
local msg="$1"
local code="${2:-1}"
log_error "$msg"
exit "$code"
}
# ─── Validation ────────────────────────────────────────────────
check_root() {
if [[ $EUID -ne 0 ]]; then
die "Script phải chạy với quyền root (sudo)" 1
fi
}
check_os() {
if [[ ! -f /etc/centos-release ]] && [[ ! -f /etc/rocky-release ]]; then
die "Script chỉ hỗ trợ CentOS / Rocky Linux" 2
fi
log_info "OS check OK: $(cat /etc/redhat-release)"
}
check_internet() {
if ! curl -sf --max-time 5 https://www.google.com > /dev/null; then
die "Không có kết nối internet" 3
fi
log_info "Internet OK"
}
# ─── Idempotent helpers ────────────────────────────────────────
# Cài package idempotent (skip nếu cài rồi)
install_package() {
local pkg="$1"
if rpm -q "$pkg" &>/dev/null; then
log_info "Package '$pkg' đã cài, skip"
else
log_info "Cài package: $pkg"
dnf install -y "$pkg" >> "$LOG_FILE" 2>&1 \
|| die "Cài package $pkg thất bại" 10
fi
}
# Enable + start service idempotent
ensure_service() {
local svc="$1"
log_info "Ensure service: $svc"
systemctl enable "$svc" >> "$LOG_FILE" 2>&1 || true
systemctl start "$svc" >> "$LOG_FILE" 2>&1 \
|| die "Start service $svc thất bại" 11
}
# Mở firewall port idempotent
open_firewall_port() {
local svc_or_port="$1"
local existing
existing=$(firewall-cmd --list-all 2>/dev/null | grep -c "$svc_or_port" || true)
if [[ "$existing" -gt 0 ]]; then
log_info "Firewall '$svc_or_port' đã mở, skip"
return
fi
log_info "Mở firewall: $svc_or_port"
firewall-cmd --permanent --add-service="$svc_or_port" 2>/dev/null \
|| firewall-cmd --permanent --add-port="${svc_or_port}/tcp" 2>/dev/null \
|| die "Mở firewall $svc_or_port thất bại" 12
firewall-cmd --reload >> "$LOG_FILE" 2>&1
}
# Thêm dòng vào file nếu chưa có
ensure_line_in_file() {
local line="$1"
local file="$2"
if grep -qF "$line" "$file" 2>/dev/null; then
log_info "Line đã có trong $file, skip"
else
log_info "Add line vào $file: $line"
echo "$line" >> "$file"
fi
}
# Sửa dòng trong file (sed replace) idempotent
replace_in_file() {
local pattern="$1"
local replacement="$2"
local file="$3"
if [[ ! -f "$file" ]]; then
die "File không tồn tại: $file" 13
fi
if grep -q "^${replacement}$" "$file"; then
log_info "Pattern đã đúng trong $file, skip"
else
log_info "Replace trong $file"
sed -i "s|${pattern}|${replacement}|" "$file"
fi
}
# ─── Retry logic ───────────────────────────────────────────────
# Retry command với exponential backoff
retry() {
local max="$1"
local delay="$2"
shift 2
local cmd="$*"
local i=1
while (( i <= max )); do
if eval "$cmd"; then
return 0
fi
log_warn "Command fail (attempt $i/$max): $cmd"
sleep "$delay"
delay=$(( delay * 2 ))
i=$(( i + 1 ))
done
return 1
}
# ─── Trap cleanup ──────────────────────────────────────────────
cleanup_on_exit() {
local code=$?
if [[ $code -eq 0 ]]; then
log_info "=== Script hoàn thành thành công ==="
else
log_error "=== Script fail với exit code $code ==="
log_error "Xem log đầy đủ tại: $LOG_FILE"
fi
}lib/10-base.sh — CentOS base setup
Module này chạy đầu tiên trên mọi server — setup các thứ chung:
#!/bin/bash
# /opt/lemp-deploy/lib/10-base.sh
# Base setup CentOS — chạy đầu tiên trên mọi server
setup_base() {
log_info "=== [10-base] Bắt đầu base setup ==="
# Tắt SELinux (lab; production phải config policy đúng)
if [[ "$(getenforce)" != "Disabled" ]]; then
log_info "Tắt SELinux"
setenforce 0 || true
sed -i 's/^SELINUX=enforcing/SELINUX=disabled/' /etc/selinux/config
else
log_info "SELinux đã disabled"
fi
# Update DNS, time sync
install_package "chrony"
ensure_service "chronyd"
# Cài tool cơ bản
install_package "vim"
install_package "wget"
install_package "curl"
install_package "net-tools"
install_package "firewalld"
ensure_service "firewalld"
# Thiết lập hostname
if [[ -n "${HOSTNAME_SET:-}" ]]; then
log_info "Set hostname: $HOSTNAME_SET"
hostnamectl set-hostname "$HOSTNAME_SET"
fi
# /etc/hosts cho domain (cho phép resolve nội bộ)
ensure_line_in_file "${PROXY_MASTER_IP} ${DOMAIN} www.${DOMAIN}" "/etc/hosts"
log_info "=== [10-base] Hoàn thành ==="
}lib/20-backend.sh — LEMP backend
#!/bin/bash
# /opt/lemp-deploy/lib/20-backend.sh
# Cài nginx + php-fpm + WordPress (không cài DB ở module này)
install_nginx_backend() {
log_info "Cài nginx"
install_package "nginx"
ensure_service "nginx"
open_firewall_port "http"
open_firewall_port "https"
}
install_php_fpm() {
log_info "Cài PHP-FPM ${PHP_VERSION}"
# EPEL + Remi repo
install_package "epel-release"
if ! rpm -q remi-release-9 &>/dev/null; then
dnf install -y "https://rpms.remirepo.net/enterprise/remi-release-9.rpm" \
>> "$LOG_FILE" 2>&1
fi
# Enable PHP module
dnf module reset php -y >> "$LOG_FILE" 2>&1 || true
dnf module enable "php:remi-${PHP_VERSION}" -y >> "$LOG_FILE" 2>&1
# Cài PHP-FPM + extensions WordPress cần
install_package "php-fpm"
install_package "php-cli"
install_package "php-mysqlnd"
install_package "php-gd"
install_package "php-mbstring"
install_package "php-xml"
install_package "php-zip"
# Fix PHP-FPM user: apache → nginx (CRITICAL — quên là 502 Bad Gateway)
local conf="/etc/php-fpm.d/www.conf"
sed -i 's/^user = apache/user = nginx/' "$conf"
sed -i 's/^group = apache/group = nginx/' "$conf"
sed -i 's/^;listen.owner = .*/listen.owner = nginx/' "$conf"
sed -i 's/^;listen.group = .*/listen.group = nginx/' "$conf"
ensure_service "php-fpm"
}
generate_ssl_cert() {
log_info "Tạo SSL self-signed cert"
install_package "openssl"
mkdir -p "$SSL_DIR"
if [[ -f "$SSL_DIR/nginx.crt" ]] && [[ -f "$SSL_DIR/nginx.key" ]]; then
log_info "SSL cert đã tồn tại, skip"
return
fi
openssl req -x509 -newkey rsa:4096 -nodes \
-keyout "$SSL_DIR/nginx.key" \
-out "$SSL_DIR/nginx.crt" \
-days "$SSL_DAYS" \
-subj "/CN=${DOMAIN}" >> "$LOG_FILE" 2>&1 \
|| die "Tạo SSL cert thất bại" 20
}
setup_vhost_backend() {
log_info "Tạo vhost backend"
mkdir -p "$WEB_ROOT"
cat > /etc/nginx/conf.d/${DOMAIN}.conf <<EOF
server {
listen 443 ssl;
server_name ${DOMAIN} www.${DOMAIN};
ssl_certificate ${SSL_DIR}/nginx.crt;
ssl_certificate_key ${SSL_DIR}/nginx.key;
ssl_protocols TLSv1.2 TLSv1.3;
root ${WEB_ROOT};
index index.php index.html;
access_log /var/log/nginx/${DOMAIN}.access.log;
error_log /var/log/nginx/${DOMAIN}.error.log warn;
client_max_body_size 100M;
location / {
try_files \$uri \$uri/ /index.php?\$args;
}
location ~ \.php\$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php-fpm/www.sock;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
fastcgi_index index.php;
}
}
server {
listen 80;
server_name ${DOMAIN} www.${DOMAIN};
return 301 https://\$host\$request_uri;
}
EOF
# Test config trước reload
nginx -t >> "$LOG_FILE" 2>&1 || die "nginx config invalid" 21
systemctl reload nginx
}
install_wordpress() {
log_info "Cài WordPress"
if [[ -f "${WEB_ROOT}/wp-config.php" ]]; then
log_info "WordPress đã cài, skip download"
else
cd "$WEB_ROOT"
curl -sL "$WORDPRESS_URL" -o /tmp/wp.tar.gz
tar -xf /tmp/wp.tar.gz -C /tmp
cp -r /tmp/wordpress/. "$WEB_ROOT/"
rm -rf /tmp/wordpress /tmp/wp.tar.gz
cp "${WEB_ROOT}/wp-config-sample.php" "${WEB_ROOT}/wp-config.php"
fi
# Config DB connection
sed -i "s/database_name_here/${DB_NAME}/" "${WEB_ROOT}/wp-config.php"
sed -i "s/username_here/${DB_USER}/" "${WEB_ROOT}/wp-config.php"
sed -i "s/password_here/${DB_PASSWORD}/" "${WEB_ROOT}/wp-config.php"
sed -i "s/localhost/${DB_MASTER_IP}/" "${WEB_ROOT}/wp-config.php"
# Add reverse proxy support nếu chưa có
if ! grep -q "HTTP_X_FORWARDED_PROTO" "${WEB_ROOT}/wp-config.php"; then
cat >> "${WEB_ROOT}/wp-config.php" <<EOF
// Reverse proxy support
if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
\$_SERVER['HTTPS'] = 'on';
}
if (isset(\$_SERVER['HTTP_X_REAL_IP'])) {
\$_SERVER['REMOTE_ADDR'] = \$_SERVER['HTTP_X_REAL_IP'];
}
define('WP_HOME', 'https://${DOMAIN}');
define('WP_SITEURL', 'https://${DOMAIN}');
EOF
fi
chown -R nginx:nginx "$WEB_ROOT"
chmod -R 755 "$WEB_ROOT"
}
setup_backend() {
log_info "=== [20-backend] Bắt đầu cài backend ==="
install_nginx_backend
install_php_fpm
generate_ssl_cert
setup_vhost_backend
install_wordpress
log_info "=== [20-backend] Hoàn thành ==="
}lib/30-db.sh — MariaDB master/slave
#!/bin/bash
# /opt/lemp-deploy/lib/30-db.sh
# MariaDB master hoặc slave (tuỳ role)
install_mariadb() {
log_info "Cài MariaDB"
install_package "mariadb-server"
ensure_service "mariadb"
open_firewall_port "3306"
}
secure_mariadb() {
log_info "Secure MariaDB (set root password)"
# Check root password đã set chưa
if mariadb -u root -p"${DB_ROOT_PASSWORD}" -e "SELECT 1;" &>/dev/null; then
log_info "Root password đã set, skip"
return
fi
# Set root password
mariadb -u root <<EOF >> "$LOG_FILE" 2>&1
ALTER USER 'root'@'localhost' IDENTIFIED BY '${DB_ROOT_PASSWORD}';
DELETE FROM mysql.user WHERE User='';
DROP DATABASE IF EXISTS test;
FLUSH PRIVILEGES;
EOF
}
setup_db_master() {
log_info "Setup DB MASTER"
# Bind 0.0.0.0 + bật binlog
local conf="/etc/my.cnf.d/server.cnf"
if ! grep -q "^server-id" "$conf"; then
cat >> "$conf" <<EOF
[mysqld]
server-id = 1
log-bin = /var/lib/mysql/mysql-bin
binlog_format = ROW
bind-address = 0.0.0.0
expire_logs_days = 7
EOF
systemctl restart mariadb
sleep 3
fi
# Tạo database + user WordPress
mariadb -u root -p"${DB_ROOT_PASSWORD}" <<EOF >> "$LOG_FILE" 2>&1
CREATE DATABASE IF NOT EXISTS ${DB_NAME};
CREATE USER IF NOT EXISTS '${DB_USER}'@'${BACKEND_1_IP}' IDENTIFIED BY '${DB_PASSWORD}';
CREATE USER IF NOT EXISTS '${DB_USER}'@'${BACKEND_2_IP}' IDENTIFIED BY '${DB_PASSWORD}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${BACKEND_1_IP}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${BACKEND_2_IP}';
CREATE USER IF NOT EXISTS '${REPL_USER}'@'${DB_SLAVE_IP}' IDENTIFIED BY '${REPL_PASSWORD}';
GRANT REPLICATION SLAVE ON *.* TO '${REPL_USER}'@'${DB_SLAVE_IP}';
FLUSH PRIVILEGES;
EOF
log_info "DB master sẵn sàng cho slave kết nối"
log_info "→ Lấy MASTER STATUS cho slave config:"
mariadb -u root -p"${DB_ROOT_PASSWORD}" -e "SHOW MASTER STATUS;" | tee -a "$LOG_FILE"
}
setup_db_slave() {
log_info "Setup DB SLAVE"
local conf="/etc/my.cnf.d/server.cnf"
if ! grep -q "^server-id" "$conf"; then
cat >> "$conf" <<EOF
[mysqld]
server-id = 2
relay-log = /var/lib/mysql/mysql-relay-bin
read_only = ON
bind-address = 0.0.0.0
EOF
systemctl restart mariadb
sleep 3
fi
# Dump từ master qua SSH (cần SSH key setup từ trước)
log_info "Dump data từ master..."
ssh -o StrictHostKeyChecking=no root@"${DB_MASTER_IP}" \
"mysqldump -u root -p${DB_ROOT_PASSWORD} --all-databases --master-data=2" \
> /tmp/master_dump.sql 2>>"$LOG_FILE" \
|| die "Dump master DB thất bại — check SSH key" 30
# Lấy binlog position từ dump
local LOG_FILE_BIN
local LOG_POS
LOG_FILE_BIN=$(grep "CHANGE MASTER TO" /tmp/master_dump.sql \
| grep -oP "MASTER_LOG_FILE='\K[^']+")
LOG_POS=$(grep "CHANGE MASTER TO" /tmp/master_dump.sql \
| grep -oP "MASTER_LOG_POS=\K[0-9]+")
log_info "Master binlog: $LOG_FILE_BIN, pos: $LOG_POS"
# Import dump
log_info "Import dump vào slave..."
mariadb -u root -p"${DB_ROOT_PASSWORD}" < /tmp/master_dump.sql
rm -f /tmp/master_dump.sql
# Setup replication
mariadb -u root -p"${DB_ROOT_PASSWORD}" <<EOF >> "$LOG_FILE" 2>&1
STOP SLAVE;
RESET SLAVE ALL;
CHANGE MASTER TO
MASTER_HOST='${DB_MASTER_IP}',
MASTER_USER='${REPL_USER}',
MASTER_PASSWORD='${REPL_PASSWORD}',
MASTER_LOG_FILE='${LOG_FILE_BIN}',
MASTER_LOG_POS=${LOG_POS};
START SLAVE;
EOF
sleep 3
log_info "Slave status:"
mariadb -u root -p"${DB_ROOT_PASSWORD}" -e "SHOW SLAVE STATUS\G" \
| grep -E "Slave_IO_Running|Slave_SQL_Running|Last_IO_Error|Last_SQL_Error" \
| tee -a "$LOG_FILE"
}
setup_db() {
local role="$1"
log_info "=== [30-db] Setup database, role: $role ==="
install_mariadb
secure_mariadb
case "$role" in
db-master)
setup_db_master
;;
db-slave)
setup_db_slave
;;
*)
die "Role không hợp lệ cho 30-db.sh: $role" 31
;;
esac
log_info "=== [30-db] Hoàn thành ==="
}lib/40-proxy.sh — nginx reverse proxy
#!/bin/bash
# /opt/lemp-deploy/lib/40-proxy.sh
# Nginx reverse proxy + load balancer
setup_proxy() {
log_info "=== [40-proxy] Setup nginx proxy ==="
install_package "nginx"
ensure_service "nginx"
open_firewall_port "http"
open_firewall_port "https"
# SSL cert (giống backend, có thể copy hoặc tạo mới)
install_package "openssl"
mkdir -p "$SSL_DIR"
if [[ ! -f "$SSL_DIR/nginx.crt" ]]; then
openssl req -x509 -newkey rsa:4096 -nodes \
-keyout "$SSL_DIR/nginx.key" \
-out "$SSL_DIR/nginx.crt" \
-days "$SSL_DAYS" \
-subj "/CN=${DOMAIN}" >> "$LOG_FILE" 2>&1
fi
# Proxy config
cat > /etc/nginx/conf.d/proxy.conf <<EOF
log_format upstreamlog '\$remote_addr - \$remote_user [\$time_local] '
'"\$request" \$status \$body_bytes_sent '
'"\$http_referer" "\$http_user_agent" '
'to: \$upstream_addr';
upstream backend_servers {
server ${BACKEND_1_IP}:443 max_fails=3 fail_timeout=30s;
server ${BACKEND_2_IP}:443 max_fails=3 fail_timeout=30s;
keepalive 32;
}
server {
listen 443 ssl;
server_name ${DOMAIN} www.${DOMAIN};
ssl_certificate ${SSL_DIR}/nginx.crt;
ssl_certificate_key ${SSL_DIR}/nginx.key;
ssl_protocols TLSv1.2 TLSv1.3;
access_log /var/log/nginx/proxy.access.log upstreamlog;
error_log /var/log/nginx/proxy.error.log warn;
client_max_body_size 100M;
location / {
proxy_pass https://backend_servers;
proxy_ssl_verify off;
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;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
server {
listen 80;
server_name ${DOMAIN} www.${DOMAIN};
return 301 https://\$host\$request_uri;
}
EOF
nginx -t >> "$LOG_FILE" 2>&1 || die "Proxy config invalid" 40
systemctl reload nginx
log_info "=== [40-proxy] Hoàn thành ==="
}lib/50-keepalived.sh — Keepalived VRRP
#!/bin/bash
# /opt/lemp-deploy/lib/50-keepalived.sh
setup_keepalived() {
local role="$1"
log_info "=== [50-keepalived] Setup keepalived, role: $role ==="
install_package "keepalived"
# Auto-detect interface chính
local iface="${KEEPALIVED_INTERFACE}"
if ! ip a show "$iface" &>/dev/null; then
iface=$(ip route | awk '/default/ {print $5; exit}')
log_warn "Interface ${KEEPALIVED_INTERFACE} không tồn tại, dùng auto-detect: $iface"
fi
# Setup check_nginx script
cat > /etc/keepalived/check_nginx.sh <<'EOF'
#!/bin/bash
if systemctl is-active --quiet nginx; then
exit 0
fi
exit 1
EOF
chmod +x /etc/keepalived/check_nginx.sh
# Config tuỳ theo role
local state priority
case "$role" in
proxy-master)
state="MASTER"
priority=101
;;
proxy-backup)
state="BACKUP"
priority=90
;;
*)
die "Role không hợp lệ cho keepalived: $role" 50
;;
esac
cat > /etc/keepalived/keepalived.conf <<EOF
vrrp_script check_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 2
weight -20
}
vrrp_instance VI_LEMP {
state ${state}
interface ${iface}
virtual_router_id ${KEEPALIVED_VRID}
priority ${priority}
advert_int 1
authentication {
auth_type PASS
auth_pass ${KEEPALIVED_AUTH_PASS}
}
virtual_ipaddress {
${VIP}
}
track_script {
check_nginx
}
}
EOF
ensure_service "keepalived"
systemctl restart keepalived
sleep 3
# Verify VIP gắn đúng (chỉ trên master)
if [[ "$role" == "proxy-master" ]]; then
if ip a | grep -q "$VIP"; then
log_info "VIP $VIP đã gắn trên master"
else
log_warn "VIP $VIP chưa thấy trên master — kiểm tra log keepalived"
fi
fi
log_info "=== [50-keepalived] Hoàn thành ==="
}lib/90-verify.sh — kiểm tra cuối
#!/bin/bash
# /opt/lemp-deploy/lib/90-verify.sh
# Test toàn bộ stack sau khi deploy
verify_stack() {
local role="$1"
log_info "=== [90-verify] Verify role: $role ==="
case "$role" in
backend-*)
verify_backend
;;
db-master)
verify_db_master
;;
db-slave)
verify_db_slave
;;
proxy-*)
verify_proxy "$role"
;;
esac
log_info "=== [90-verify] Hoàn thành ==="
}
verify_backend() {
log_info "Verify nginx running..."
systemctl is-active --quiet nginx || log_error "nginx KHÔNG chạy"
log_info "Verify php-fpm running..."
systemctl is-active --quiet php-fpm || log_error "php-fpm KHÔNG chạy"
log_info "Verify socket php-fpm..."
if [[ -S /var/run/php-fpm/www.sock ]]; then
log_info "Socket OK"
else
log_error "Socket KHÔNG tồn tại"
fi
log_info "Verify WordPress files..."
if [[ -f "${WEB_ROOT}/wp-config.php" ]]; then
log_info "wp-config.php OK"
else
log_error "wp-config.php KHÔNG có"
fi
log_info "Verify local PHP..."
curl -sk "https://localhost/" -o /dev/null -w "HTTP %{http_code}\n" | tee -a "$LOG_FILE"
}
verify_db_master() {
log_info "Verify MariaDB master..."
systemctl is-active --quiet mariadb || log_error "mariadb KHÔNG chạy"
log_info "Verify binlog enabled..."
local log_bin
log_bin=$(mariadb -u root -p"${DB_ROOT_PASSWORD}" -sN \
-e "SHOW VARIABLES LIKE 'log_bin';" | awk '{print $2}')
if [[ "$log_bin" == "ON" ]]; then
log_info "Binlog ON"
else
log_error "Binlog OFF"
fi
}
verify_db_slave() {
log_info "Verify MariaDB slave..."
local io_running sql_running
io_running=$(mariadb -u root -p"${DB_ROOT_PASSWORD}" -e "SHOW SLAVE STATUS\G" 2>/dev/null \
| grep "Slave_IO_Running:" | awk '{print $2}')
sql_running=$(mariadb -u root -p"${DB_ROOT_PASSWORD}" -e "SHOW SLAVE STATUS\G" 2>/dev/null \
| grep "Slave_SQL_Running:" | awk '{print $2}')
if [[ "$io_running" == "Yes" && "$sql_running" == "Yes" ]]; then
log_info "Replication chạy OK (IO=Yes, SQL=Yes)"
else
log_error "Replication LỖI (IO=$io_running, SQL=$sql_running)"
fi
}
verify_proxy() {
local role="$1"
log_info "Verify nginx..."
systemctl is-active --quiet nginx || log_error "nginx KHÔNG chạy"
log_info "Verify keepalived..."
systemctl is-active --quiet keepalived || log_error "keepalived KHÔNG chạy"
if [[ "$role" == "proxy-master" ]]; then
log_info "Verify VIP gắn..."
ip a | grep -q "$VIP" \
&& log_info "VIP $VIP gắn OK" \
|| log_error "VIP KHÔNG gắn"
fi
log_info "Verify connect backend..."
curl -sk --max-time 5 "https://${BACKEND_1_IP}/" -o /dev/null -w "Backend-1: HTTP %{http_code}\n" | tee -a "$LOG_FILE"
curl -sk --max-time 5 "https://${BACKEND_2_IP}/" -o /dev/null -w "Backend-2: HTTP %{http_code}\n" | tee -a "$LOG_FILE"
}deploy.sh — Main orchestrator
#!/bin/bash
# /opt/lemp-deploy/deploy.sh
# Main orchestrator — dispatch theo role
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ─── Load config ────────────────────────────────────────────────
if [[ ! -f "${SCRIPT_DIR}/config.conf" ]]; then
echo "ERROR: config.conf không tồn tại" >&2
exit 1
fi
# shellcheck source=config.conf
source "${SCRIPT_DIR}/config.conf"
# Tạo log dir trước khi load common
mkdir -p "$LOG_DIR"
touch "$LOG_FILE"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
# ─── Trap cleanup ───────────────────────────────────────────────
trap cleanup_on_exit EXIT
# ─── Parse args ─────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $0 <role>
Roles:
backend-1, backend-2 - LEMP backend server
db-master - MariaDB master
db-slave - MariaDB slave (cần SSH key tới db-master)
proxy-master - Nginx proxy + Keepalived MASTER
proxy-backup - Nginx proxy + Keepalived BACKUP
all - Chạy tất cả (cần SSH key tới mọi server)
Examples:
sudo $0 backend-1
sudo $0 db-master
Log file sẽ ghi ra: ${LOG_DIR}/
EOF
exit 1
}
if [[ $# -lt 1 ]]; then
usage
fi
ROLE="$1"
# ─── Validation ─────────────────────────────────────────────────
check_root
check_os
check_internet
log_info "================================================"
log_info "Deploy LEMP HA Stack"
log_info "Role: $ROLE"
log_info "Hostname: $(hostname)"
log_info "Log: $LOG_FILE"
log_info "================================================"
# ─── Load modules ───────────────────────────────────────────────
# shellcheck source=lib/10-base.sh
source "${SCRIPT_DIR}/lib/10-base.sh"
# shellcheck source=lib/20-backend.sh
source "${SCRIPT_DIR}/lib/20-backend.sh"
# shellcheck source=lib/30-db.sh
source "${SCRIPT_DIR}/lib/30-db.sh"
# shellcheck source=lib/40-proxy.sh
source "${SCRIPT_DIR}/lib/40-proxy.sh"
# shellcheck source=lib/50-keepalived.sh
source "${SCRIPT_DIR}/lib/50-keepalived.sh"
# shellcheck source=lib/90-verify.sh
source "${SCRIPT_DIR}/lib/90-verify.sh"
# ─── Dispatch theo role ─────────────────────────────────────────
case "$ROLE" in
backend-1|backend-2)
HOSTNAME_SET="${ROLE}.local"
setup_base
setup_backend
verify_stack "$ROLE"
;;
db-master)
HOSTNAME_SET="db-master.local"
setup_base
setup_db "db-master"
verify_stack "db-master"
;;
db-slave)
HOSTNAME_SET="db-slave.local"
setup_base
setup_db "db-slave"
verify_stack "db-slave"
;;
proxy-master|proxy-backup)
HOSTNAME_SET="${ROLE}.local"
setup_base
setup_proxy
setup_keepalived "$ROLE"
verify_stack "$ROLE"
;;
all)
# SSH ra mọi server trong inventory và chạy
deploy_all
;;
*)
log_error "Role không hợp lệ: $ROLE"
usage
;;
esac
log_info ""
log_info "✅ Deploy '$ROLE' thành công."
log_info "Log đầy đủ: $LOG_FILE"Function deploy_all — SSH orchestration (optional)
Thêm vào lib/common.sh:
# Đọc inventory và SSH ra từng server
deploy_all() {
local inv="${SCRIPT_DIR}/inventory.conf"
[[ -f "$inv" ]] || die "Inventory không tồn tại: $inv" 60
# Thứ tự deploy: base → DB (master trước) → backend → proxy
local order=(db-master backend-1 backend-2 db-slave proxy-master proxy-backup)
for role in "${order[@]}"; do
local ip
ip=$(grep "^${role}:" "$inv" | cut -d: -f2)
if [[ -z "$ip" ]]; then
log_warn "Không tìm thấy $role trong inventory, skip"
continue
fi
log_info "================================================"
log_info "Deploy $role tại $ip"
log_info "================================================"
# Copy script lên server
ssh -o StrictHostKeyChecking=no "root@${ip}" "mkdir -p /opt/lemp-deploy" \
|| die "SSH tới $ip thất bại" 61
rsync -avz --delete "${SCRIPT_DIR}/" "root@${ip}:/opt/lemp-deploy/" \
>> "$LOG_FILE" 2>&1
# Chạy script trên server
ssh "root@${ip}" "cd /opt/lemp-deploy && bash deploy.sh $role" \
|| die "Deploy $role thất bại" 62
log_info "✅ $role done"
done
log_info "================================================"
log_info "🎉 Toàn bộ stack đã deploy thành công"
log_info "================================================"
}Run lab — chạy thử
Chuẩn bị
6 server fresh CentOS 9, đã cấu hình IP tĩnh và kết nối được internet.
Cách 1: SSH vào từng server, chạy local
# Trên backend-1
ssh root@192.168.32.133
git clone <your-repo>/lemp-deploy /opt/lemp-deploy
cd /opt/lemp-deploy
sudo ./deploy.sh backend-1
# Tương tự cho mỗi role
ssh root@192.168.32.134 → sudo ./deploy.sh backend-2
ssh root@192.168.32.137 → sudo ./deploy.sh db-master
ssh root@192.168.32.138 → sudo ./deploy.sh db-slave
ssh root@192.168.32.135 → sudo ./deploy.sh proxy-master
ssh root@192.168.32.136 → sudo ./deploy.sh proxy-backupThứ tự quan trọng:
db-mastertrước (để slave có cái để replicate).backend-1,backend-2.db-slave(cần ssh tới db-master để dump).proxy-master,proxy-backup.
Cách 2: SSH key + chạy từ 1 control node
# Trên control node (PC của bạn)
ssh-keygen -t ed25519 -f ~/.ssh/lemp_deploy
for ip in 133 134 135 136 137 138; do
ssh-copy-id -i ~/.ssh/lemp_deploy root@192.168.32.${ip}
done
git clone <repo> ~/lemp-deploy
cd ~/lemp-deploy
# Chạy 1 lệnh, deploy toàn bộ
sudo ./deploy.sh allOutput dự kiến:
[2024-12-15 10:00:00] [INFO] ================================================
[2024-12-15 10:00:00] [INFO] Deploy db-master tại 192.168.32.137
[2024-12-15 10:00:00] [INFO] ================================================
[2024-12-15 10:00:01] [INFO] OS check OK: CentOS Stream release 9
[2024-12-15 10:00:01] [INFO] Internet OK
[2024-12-15 10:00:02] [INFO] === [10-base] Bắt đầu base setup ===
...
[2024-12-15 10:08:30] [INFO] ✅ proxy-backup done
[2024-12-15 10:08:30] [INFO] 🎉 Toàn bộ stack đã deploy thành côngThời gian: ~ 8-10 phút cho cả 6 server (so với 12 tiếng cài tay).
Verify
Trên máy client, thêm vào /etc/hosts:
192.168.32.100 test.comVào https://test.com → WordPress wizard hiện ra. Hoàn tất setup qua browser.
Test failover:
# Trên proxy-master
sudo systemctl stop keepalived
# Trên proxy-backup, kiểm tra
ip a | grep 32.100 # VIP đã chuyển sang!Câu chuyện thực tế
Đầu 2024, mình deploy hệ thống e-learning cho 1 trường đại học. Client list: 30 server ở 3 data center (production, staging, DR).
Lần đầu cài tay 1 stack (6 server) — 1 ngày. Nhẩm tính: 30 server / 6 × 1 ngày = 5 ngày chỉ để dựng infrastructure.
Quyết định: spend 2 ngày viết automation script. Result:
- Day 1-2: viết + test script trên 1 stack local.
- Day 3 sáng: chạy script ra tất cả 30 server, xong trong 1 tiếng.
- Day 3 chiều: smoke test, fix vài config nhỏ.
- Day 4-5: rảnh để handle những việc khác.
Tổng tiết kiệm: 2 ngày + giảm 90% nguy cơ sai sót.
3 tháng sau, trường mở campus mới — cần dựng staging tương tự. Chạy lại script: 30 phút. Tay không cần đụng tới các bước manual.
Quan trọng hơn nữa: 6 tháng sau, có sự cố data center A — phải restore lên data center B. Trong stress 2h sáng, mọi người panic. Mình chỉ chạy:
./deploy.sh all3 tiếng sau, stack mới UP ở DC B, restore data từ backup. Nếu phải cài tay 30 server lúc đó, không biết sẽ thế nào.
Bài học:
Đầu tư automation từ project thứ 2. Project đầu cài tay để hiểu hệ thống. Project thứ 2 phải script.
Script chính là documentation. 1 năm sau quay lại, đọc script còn nhanh hơn đọc wiki.
Test idempotency nghiêm túc. Chạy lại lần 2, lần 3 — không break là OK.
Logging chi tiết là cứu cánh. Lúc fail giữa chừng, đọc log biết stuck ở đâu, fix nhanh.
Automation tốt = ngủ ngon. Disaster recovery không còn là nightmare nếu script chạy được.
Pitfalls của shell automation
Hardcode IP, password trong script — không reuse được. Tách config riêng.
Không idempotent — chạy lần 2 phá hỏng lần 1. Mọi action phải check trước.
Không log đủ — fail không biết tại sao. Log mọi command vào file.
Không có error handling —
set -echỉ thôi không đủ. Cầntrap ERR+die()function.Run as root mà không check — script lỡ tay chạy với user thường → permission denied giữa chừng, fix khó.
Mật khẩu plain text trong file — secret leak. Production: dùng vault, encrypt at rest.
SSH key sharing không cẩn thận — script chạy với key có quyền root toàn cụm. Nếu repo leak = catastrophe. Dùng SSH agent forward hoặc per-server key.
Quên test trên fresh server — script chạy OK trên dev nhưng fail trên prod fresh vì env khác. Always test trên VM clean.
Race condition giữa modules — vd: slave start trước khi master tạo user. Đặt dependency rõ ràng, dùng
sleephoặc check loop.Không có rollback — fail giữa chừng để lại state dở dang. Production cần rollback hoặc state tracking (touch
/opt/.deploy-state-<role>-done).Script dài 2000 dòng trong 1 file — không maintain nổi. Tách module ngay khi > 500 dòng.
Không ShellCheck — bug syntax/quote ẩn dấu nhiều năm. Chạy CI mỗi PR.
Khi nào chuyển sang Ansible/Terraform?
Shell script tốt cho:
- ≤ 20 server.
- Setup tương đối đồng nhất.
- Team nhỏ (1-3 người), ai cũng biết bash.
- Stack stable, ít thay đổi.
Khi nào nên chuyển:
Ansible — config management
Chuyển khi:
- Quản lý > 20 server với nhiều role khác nhau.
- Cần idempotency tự động (Ansible module built-in).
- Team có nhiều người, bash script phức tạp khó maintain.
- Cần inventory động (cloud instance tag-based).
Ansible giống shell script tổ chức tốt + có DSL declarative:
- name: Install nginx
package:
name: nginx
state: present
- name: Start nginx
service:
name: nginx
state: started
enabled: yesĐọc xong shell script này, học Ansible 1 tuần là làm được. Concept giống nhau.
Terraform — provisioning
Chuyển khi:
- Quản lý cloud resource (EC2, VPC, RDS).
- Cần state tracking infrastructure (server đang có gì).
- Multi-cloud (AWS + GCP).
Terraform và Ansible bổ sung nhau: Terraform tạo VM, Ansible config bên trong VM. Không thay thế.
Containerization
Bài học song song: nếu app có thể container hoá → 90% công của bài 04 không cần. Docker + Kubernetes là tương lai của LEMP stack deployment.
Nhưng học automation script bash trước có giá trị riêng: bạn hiểu hệ thống ở mức bare-metal. Khi Docker/K8s có vấn đề (mà sẽ có), bạn debug được. Ngược lại không đúng.
Tóm tắt
- Manual lần 1 để hiểu, automate từ lần 2.
- Idempotency = chạy nhiều lần không vỡ. Check trước khi làm.
- Imperative (Bash) vs Declarative (Ansible). Shell đủ cho ≤ 20 server.
- Cấu trúc:
deploy.sh+config.conf+lib/modules. - Common functions:
log_info(),die(),install_package(),ensure_service()— viết 1 lần dùng nhiều nơi. - Modules theo dependency order: base → DB → backend → proxy/keepalived.
- 2 chế độ run: local mỗi server hoặc SSH từ control node.
- Verify module chạy cuối — đảm bảo deploy thực sự work.
- ShellCheck + test fresh server trước khi production.
- Script chính là documentation. Viết clean, comment rõ.
🎓 Kết thúc Chặng 4 — Web Stack & High Availability
Đi qua 4 bài:
- Bài 01 — LEMP Stack: dựng web service đầu tiên, hiểu 4 thành phần.
- Bài 02 — Nginx Reverse Proxy: 1 proxy → N backend, load balance, SSL termination, WordPress sau proxy.
- Bài 03 — High Availability: Keepalived VRRP + MySQL Replication. Hệ thống không còn SPoF.
- Bài 04 — Automation: tự động hoá toàn bộ — từ manual workflow đến IaC entry-level.
Từ 1 server WordPress cài tay → kiến trúc multi-tier production thật → script tự động dựng 6 server trong 10 phút.
Bạn đã sẵn sàng cho
- Container (Docker): containerize stack này thành image, deploy bằng Docker Compose. Pattern tương tự, layer khác.
- Orchestration (Kubernetes): pattern bài 02-03 (Service, Ingress, StatefulSet) chính là kế thừa của những gì bạn vừa học.
- CI/CD: integrate script này vào pipeline (Jenkins, GitLab CI, GitHub Actions).
- Infrastructure as Code: Ansible playbook viết lại script này — bài tập tốt.
- Observability: monitor stack với Prometheus + Grafana + Loki.
Lời khuyên đi tiếp
Đừng vội sang K8s. Hiểu LEMP + HA + automation trên bare-metal trước. K8s nhìn vào sẽ thấy quen, không bị overwhelmed.
Viết blog/wiki nội bộ. Đứa nhỏ hỏi "công ty mình deploy thế nào?" — chỉ vào tài liệu này. Tự động onboard.
Refactor script này thành Ansible — bài tập 1 tuần. Học pattern declarative.
Container hoá — Dockerfile cho LEMP, docker-compose cho HA. Bài tập 2 tuần.
Backup là không thể thiếu. Script deploy không phải backup. Đầu tư riêng cho backup + test restore.
Chặng tiếp theo là Virtualization & Container. Nếu hôm nay bạn dựng được full HA stack từ script bash — bạn đã có nền vững nhất để đi tiếp.
Chúc bạn build được hệ thống mà 2h sáng không phải sợ điện thoại reng! 🚀
— Minh Hưng