BÀI VIẾT // [DEVOPS] Tự động hóa SSH với Bash: Quản lý multi-server hiệu quả trong DevOps
Tự động hóa SSH với Bash: Quản lý multi-server hiệu quả trong DevOps

Tự động hóa SSH với Bash: Quản lý multi-server hiệu quả trong DevOps

Hướng dẫn tự động hóa SSH với Bash để quản lý multi-server: ssh-keygen, ssh-copy-id, ~/.ssh/config, scp, rsync, chạy lệnh từ xa song song với & và wait.

DEVOPS BASH

Tự động hóa SSH với Bash trong DevOps

Trong bài viết trước, chúng ta đã tìm hiểu cách giám sát tài nguyên hệ thống và can thiệp tiến trình ngay trên một server duy nhất. Nhưng trong thực tế DevOps, bạn hiếm khi chỉ quản lý một máy. Khi hạ tầng mở rộng đến 10, 50 hay thậm chí hàng trăm server, việc SSH thủ công vào từng máy để chạy lệnh là một ác mộng — chậm, dễ sai và không thể lặp lại.

Tự động hóa SSH bằng Bash giải quyết vấn đề này: bạn viết một lần, chạy trên mọi server, kết quả đồng nhất và có thể tích hợp vào pipeline CI/CD hoặc cron job. Bài viết này sẽ hướng dẫn bạn thiết lập SSH key-based authentication, cấu hình ~/.ssh/config để quản lý nhiều server, truyền file an toàn với scprsync, cùng một script thực hành chạy lệnh song song trên nhiều server.


Thiết lập SSH key-based authentication

SSH key-based authentication loại bỏ việc nhập mật khẩu每次 khi kết nối — đây là yêu cầu bắt buộc cho bất kỳ script tự động hóa nào.

Tạo SSH key pair

1
2
3
4
5
# Tạo key RSA 4096-bit
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_deploy -C "deploy@automation"

# Hoặc dùng Ed25519 (nhanh hơn, an toàn hơn)
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_deploy -C "deploy@automation"

Lưu ý: Nếu bạn dùng key cho mục đích tự động hóa, hãy đặt passphrase rỗng (nhấn Enter khi được hỏi) hoặc dùng ssh-agent để quản lý passphrase. Tuy nhiên, trong môi trường CI/CD, nên dùng key không passphrase và bảo vệ key bằng quyền truy cập filesystem.

Copy key sang server

1
2
3
4
5
6
# Dùng ssh-copy-id (cách chuẩn)
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub user@server1
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub user@server2

# Hoặc copy thủ công nếu ssh-copy-id không khả dụng
cat ~/.ssh/id_ed25519_deploy.pub | ssh user@server1 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

Kiểm tra kết nối

1
2
# Test không cần mật khẩu
ssh -i ~/.ssh/id_ed25519_deploy user@server1 "echo 'SSH connection successful'"

Cấu hình ~/.ssh/config

File ~/.ssh/config giúp bạn truy cập server bằng tên ngắn thay vì nhớ địa chỉ IP và username:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# ~/.ssh/config

# Server staging
Host staging-web
    HostName 192.168.1.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 22

Host staging-db
    HostName 192.168.1.11
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 22

# Server production
Host prod-web
    HostName 10.0.0.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 2222

Host prod-db
    HostName 10.0.0.11
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 2222

# Global defaults
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    StrictHostKeyChecking accept-new
    LogLevel ERROR

Sau khi cấu hình, bạn có thể truy cập server bằng tên host ngắn:

1
2
3
4
5
# Thay vì: ssh [email protected]
ssh staging-web "df -h /"

# Hoặc dùng alias trong Bash
echo "alias prod-web='ssh prod-web'" >> ~/.bashrc

Giải thích các cấu hình quan trọng:

  • ServerAliveInterval 60: Gửi keepalive mỗi 60 giây để tránh bị ngắt kết nối.
  • ServerAliveCountMax 3: Ngắt kết nối sau 3 lần keepalive thất bại.
  • StrictHostKeyChecking accept-new: Tự động chấp nhận host key mới mà không hỏi.
  • LogLevel ERROR: Chỉ hiển thị lỗi, giảm verbosity khi chạy script.

Chạy lệnh từ xa

Lệnh cơ bản

1
2
3
4
5
6
7
8
# Chạy một lệnh trên server
ssh user@server1 "uptime"

# Chạy nhiều lệnh
ssh user@server1 "cd /opt/app && git pull && systemctl restart myapp"

# Chạy lệnh với sudo
ssh user@server1 "sudo systemctl status nginx"

Truyền biến môi trường

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Truyền biến vào phiên SSH
DEPLOY_ENV="staging" APP_VERSION="v2.1.0" ssh user@server1 "echo \$DEPLOY_ENV \$APP_VERSION"

# An toàn hơn: dùng heredoc
ssh user@server1 << 'REMOTE_SCRIPT'
    export DEPLOY_ENV="staging"
    cd /opt/app
    git pull origin main
    npm install --production
    systemctl restart myapp
REMOTE_SCRIPT

Xử lý lỗi khi chạy remote

1
2
3
4
5
6
7
# Kiểm tra exit code
if ssh user@server1 "systemctl status nginx" 2>/dev/null; then
    echo "Nginx is running on server1"
else
    echo "Nginx is down on server1 — attempting restart"
    ssh user@server1 "sudo systemctl restart nginx"
fi

Truyền file giữa các server

scp — Secure Copy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Copy file từ local sang remote
scp /local/path/config.yml user@server1:/opt/app/config.yml

# Copy thư mục (recursive)
scp -r /local/path/dist/ user@server1:/opt/app/dist/

# Copy từ remote sang local
scp user@server1:/var/log/app.log /local/logs/

# Copy giữa hai server (qua local trung gian)
scp user@server1:/data/backup.sql user@server2:/data/restore.sql

rsync — Remote Sync

rsync mạnh hơn scp vì hỗ trợ đồng bộ chỉ các file thay đổi (incremental sync):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Đồng bộ thư mụcdist lên server
rsync -avz --progress /local/path/dist/ user@server1:/opt/app/dist/

# rsync qua SSH với port tùy chỉnh
rsync -avz -e "ssh -p 2222" /local/path/ user@server1:/opt/app/

# Chỉ sync file đã thay đổi trong 1 ngày
rsync -avz --max-age=1 /local/path/logs/ user@server1:/backup/logs/

# Dry-run trước khi thực hiện
rsync -avzn --delete /local/path/ user@server1:/opt/app/

Giải thích flags:

  • -a: Archive mode (giữ quyền file, symlink, timestamp).
  • -v: Verbose — hiển thị file đang được sync.
  • -z: Nén dữ liệu khi truyền qua mạng.
  • --delete: Xóa file ở destination không có trong source.
  • --progress: Hiển thị tiến trình truyền file.

Lặp qua nhiều server

Vòng lặp cơ bản

1
2
3
4
5
6
SERVERS=("web-01" "web-02" "web-03")

for server in "${SERVERS[@]}"; do
    echo "=== $server ==="
    ssh "deploy@${server}" "hostname && uptime && df -h / | tail -1"
done

Đọc danh sách server từ file

1
2
3
4
5
6
7
8
9
# File servers.txt chứa mỗi server một dòng
# web-01
# web-02
# db-01

while IFS= read -r server; do
    [[ -z "$server" || "$server" =~ ^# ]] && continue
    ssh "deploy@${server}" "hostname" 2>/dev/null && echo "OK: $server" || echo "FAIL: $server"
done < servers.txt

Xử lý lỗi trong vòng lặp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
SERVERS=("web-01" "web-02" "web-03")
FAILED=()

for server in "${SERVERS[@]}"; do
    if ! ssh "deploy@${server}" "echo OK" 2>/dev/null; then
        FAILED+=("$server")
        echo "[ERROR] Cannot connect to $server"
    fi
done

if [[ ${#FAILED[@]} -gt 0 ]]; then
    echo "Failed servers: ${FAILED[*]}"
    exit 1
fi

Chạy lệnh song song với & và wait

Khi quản lý nhiều server, chạy tuần tự từng máy sẽ rất chậm. Bash hỗ trợ chạy song song bằng & (background process) và wait (đợi tất cả hoàn thành):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
SERVERS=("web-01" "web-02" "web-03" "db-01" "db-02")
MAX_PARALLEL=3

run_on_server() {
    local server="$1"
    echo "[START] $server"
    ssh "deploy@${server}" "sudo apt update && sudo apt upgrade -y" 2>/dev/null
    local status=$?
    echo "[DONE] $server (exit: $status)"
    return $status
}

# Chạy song song với giới hạn concurrency
pids=()
for server in "${SERVERS[@]}"; do
    run_on_server "$server" &
    pids+=($!)

    # Giới hạn số tiến trình chạy đồng thời
    if [[ ${#pids[@]} -ge $MAX_PARALLEL ]]; then
        wait "${pids[0]}"
        pids=("${pids[@]:1}")
    fi
done

# Đợi tất cả tiến trình còn lại hoàn thành
for pid in "${pids[@]}"; do
    wait "$pid"
done

echo "All servers updated."

Giải thích:

  • run_on_server "$server" &: Chạy hàm trong background.
  • pids+=($!): Lưu PID của tiến trình background.
  • wait "${pids[0]}": Đợi tiến trình đầu tiên hoàn thành trước khi chạy tiến trình mới.
  • MAX_PARALLEL=3: Giới hạn tối đa 3 server chạy cùng lúc.

Ví dụ thực hành: Script quản lý multi-server

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#!/usr/bin/env bash
# ==============================================================================
# Script : multi-server-deploy.sh
# Mục đích: Triển khai ứng dụng lên nhiều server song song
# ==============================================================================

set -euo pipefail
IFS=$'\n\t'

readonly SERVERS_FILE="${SERVERS_FILE:-/etc/deploy/servers.txt}"
readonly SSH_USER="${SSH_USER:-deploy}"
readonly SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_deploy}"
readonly APP_DIR="${APP_DIR:-/opt/app}"
readonly MAX_PARALLEL="${MAX_PARALLEL:-5}"
readonly LOG_DIR="/var/log/multi-deploy"
readonly TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
readonly LOG_FILE="${LOG_DIR}/deploy_${TIMESTAMP}.log"

mkdir -p "$LOG_DIR"

log() {
    local level="$1"; shift
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
}

load_servers() {
    if [[ ! -f "$SERVERS_FILE" ]]; then
        log "ERROR" "Servers file not found: $SERVERS_FILE"
        exit 1
    fi

    mapfile -t SERVERS < <(grep -vE '^\s*#|^\s*$' "$SERVERS_FILE")

    if [[ ${#SERVERS[@]} -eq 0 ]]; then
        log "ERROR" "No servers defined in $SERVERS_FILE"
        exit 1
    fi

    log "INFO" "Loaded ${#SERVERS[@]} servers from $SERVERS_FILE"
}

check_connectivity() {
    local server="$1"
    local identity_file="$SSH_KEY"

    if [[ ! -f "$identity_file" ]]; then
        log "ERROR" "SSH key not found: $identity_file"
        exit 1
    fi

    ssh -i "$identity_file" -o ConnectTimeout=10 -o BatchMode=yes \
        "${SSH_USER}@${server}" "echo OK" 2>/dev/null
}

deploy_to_server() {
    local server="$1"
    local deploy_log="${LOG_DIR}/deploy_${server}_${TIMESTAMP}.log"

    log "INFO" "Deploying to $server..."

    if ! check_connectivity "$server"; then
        log "ERROR" "Cannot connect to $server — skipping"
        return 1
    fi

    ssh -i "$SSH_KEY" "${SSH_USER}@${server}" << REMOTE > "$deploy_log" 2>&1
        set -euo pipefail

        echo "=== Deployment on \$(hostname) ==="
        echo "Time: \$(date)"

        cd "$APP_DIR" || { echo "Directory $APP_DIR not found"; exit 1; }

        echo "Pulling latest code..."
        git pull origin main

        echo "Installing dependencies..."
        npm install --production 2>/dev/null || pip install -r requirements.txt 2>/dev/null || true

        echo "Restarting service..."
        sudo systemctl restart myapp

        echo "Checking service status..."
        sleep 3
        if systemctl is-active --quiet myapp; then
            echo "Service myapp is running"
        else
            echo "ERROR: Service myapp failed to start"
            exit 1
        fi

        echo "=== Deployment completed ==="
REMOTE

    if [[ $? -eq 0 ]]; then
        log "INFO" "Deploy SUCCESS: $server"
    else
        log "ERROR" "Deploy FAILED: $server (check $deploy_log)"
        return 1
    fi
}

main() {
    log "INFO" "=== Multi-server deployment started ==="

    load_servers

    pids=()
    failed=()
    succeeded=0

    for server in "${SERVERS[@]}"; do
        deploy_to_server "$server" &
        pids+=($!)

        if [[ ${#pids[@]} -ge $MAX_PARALLEL ]]; then
            wait "${pids[0]}" || failed+=("${SERVERS[0]}")
            pids=("${pids[@]:1}")
        fi
    done

    for pid in "${pids[@]}"; do
        wait "$pid" || failed+=("server")
    done

    log "INFO" "=== Deployment completed ==="
    log "INFO" "Results: $(( ${#SERVERS[@]} - ${#failed[@]} ))/${#SERVERS[@]} succeeded"

    if [[ ${#failed[@]} -gt 0 ]]; then
        log "WARN" "Failed servers: ${failed[*]}"
        exit 1
    fi
}

main "$@"

Cấu trúc file servers.txt

1
2
3
4
5
6
7
8
# Staging servers
web-staging-01
web-staging-02

# Production servers
web-prod-01
web-prod-02
web-prod-03

Chạy script

1
2
chmod +x multi-server-deploy.sh
./multi-server-deploy.sh

Ghi chú triển khai

  • Bảo mật SSH key: Đặt quyền chmod 600 cho private key và chmod 644 cho public key. Không bao giờ commit key vào repository. Trong CI/CD, lưu key dưới dạng secret và mount vào file tạm thời.
  • Host key verification: Ở第一次 kết nối, SSH sẽ hỏi xác nhận host key. Trong script tự động, dùng StrictHostKeyChecking accept-new trong ~/.ssh/config hoặc flag -o StrictHostKeyChecking=accept-new để tự động chấp nhận.
  • Giới hạn concurrency: Chạy quá nhiều SSH session cùng lúc có thể gây overload cho network hoặc server nguồn. Luôn thiết lập MAX_PARALLEL phù hợp với hạ tầng.
  • Timeout kết nối: Sử dụng -o ConnectTimeout=10-o BatchMode=yes để tránh script treo vô hạn khi server không phản hồi.
  • Rollback strategy: Luôn có kế hoạch rollback trước khi deploy hàng loạt. Script ví dụ trên chỉ deploy — trong thực tế, bạn nên thêm bước snapshot hoặc backup trước khi thay đổi.

Lời kết

Tự động hóa SSH bằng Bash không chỉ giúp bạn tiết kiệm thời gian mà còn đảm bảo tính nhất quán khi quản lý nhiều server. Từ việc thiết lập key-based authentication, cấu hình ~/.ssh/config để truy cập nhanh, sử dụng scp/rsync để truyền file, đến việc chạy lệnh song song với &wait — tất cả đều có thể kết hợp trong một script Bash gọn gàng.

bài tiếp theo, chúng ta sẽ khám phá cách kết hợp Bash và Docker: viết wrapper script để quản lý container, tự động restart khi crash, và deploy stack với docker compose.

THẢO LUẬN & BÌNH LUẬN