Bài 04

Automation: Script tự động dựng full LEMP + HA stack

11 tháng 5, 2026 · 120 phút đọc

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.
  • 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/bashrc

Mọ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:

  1. Check trước khi làm: [[ -f file ]] || cp src file
  2. Idempotent commands: mkdir -p (OK nếu exists), dnf install -y (skip nếu cài rồi).
  3. 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:

  1. Strict mode đầu mỗi script:

    #!/bin/bash
    set -euo pipefail
  2. Quote biến luôn: "$var", không $var.

  3. local trong function, không leak biến.

  4. trap cleanup EXIT đảm bảo cleanup chắc chắn chạy.

  5. Log với timestamplog_info(), log_error() thay echo.

  6. Exit code đúng nghĩa0 = OK, khác 0 = lỗi cụ thể.

  7. Idempotent — script chạy lại không vỡ.

  8. Variables tập trung 1 chỗ — config file, không hardcode trong code.

  9. Functions ngắn, single purposeinstall_nginx(), configure_php_fpm().

  10. 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 & verify

2 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-master

Cách 2: Từ control node, SSH ra toàn bộ (production):

./deploy.sh all   # SSH ra hết các server trong inventory

Bà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.sh

Mọ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.local

lib/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-backup

Thứ tự quan trọng:

  1. db-master trước (để slave có cái để replicate).
  2. backend-1, backend-2.
  3. db-slave (cần ssh tới db-master để dump).
  4. 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 all

Output 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ông

Thờ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.com

Và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 all

3 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:

  1. Đầu tư automation từ project thứ 2. Project đầu cài tay để hiểu hệ thống. Project thứ 2 phải script.

  2. Script chính là documentation. 1 năm sau quay lại, đọc script còn nhanh hơn đọc wiki.

  3. Test idempotency nghiêm túc. Chạy lại lần 2, lần 3 — không break là OK.

  4. Logging chi tiết là cứu cánh. Lúc fail giữa chừng, đọc log biết stuck ở đâu, fix nhanh.

  5. Automation tốt = ngủ ngon. Disaster recovery không còn là nightmare nếu script chạy được.


Pitfalls của shell automation

  1. Hardcode IP, password trong script — không reuse được. Tách config riêng.

  2. Không idempotent — chạy lần 2 phá hỏng lần 1. Mọi action phải check trước.

  3. Không log đủ — fail không biết tại sao. Log mọi command vào file.

  4. Không có error handlingset -e chỉ thôi không đủ. Cần trap ERR + die() function.

  5. 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ó.

  6. Mật khẩu plain text trong file — secret leak. Production: dùng vault, encrypt at rest.

  7. 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.

  8. 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.

  9. Race condition giữa modules — vd: slave start trước khi master tạo user. Đặt dependency rõ ràng, dùng sleep hoặc check loop.

  10. 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).

  11. Script dài 2000 dòng trong 1 file — không maintain nổi. Tách module ngay khi > 500 dòng.

  12. 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:

  1. Bài 01 — LEMP Stack: dựng web service đầu tiên, hiểu 4 thành phần.
  2. Bài 02 — Nginx Reverse Proxy: 1 proxy → N backend, load balance, SSL termination, WordPress sau proxy.
  3. Bài 03 — High Availability: Keepalived VRRP + MySQL Replication. Hệ thống không còn SPoF.
  4. 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

  1. Đừ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.

  2. 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.

  3. Refactor script này thành Ansible — bài tập 1 tuần. Học pattern declarative.

  4. Container hoá — Dockerfile cho LEMP, docker-compose cho HA. Bài tập 2 tuần.

  5. 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

← Bài trước
Bài 03
Zalo tư vấn