Script vận hành nên bắt đầu bằng strict mode, kiểm cú pháp bằng bash -n script.sh và lint bằng shellcheck script.sh trước khi chạy.
Khung script
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 [-n] [-o outdir] -f hosts.txt" >&2
exit 2
}
log() { printf '%s %s\n' "$(date -Is)" "$*" >&2; }
DRY_RUN=0
OUTDIR="./out"
HOSTS_FILE=""
while getopts ":nf:o:h" opt; do
case "$opt" in
n) DRY_RUN=1 ;;
f) HOSTS_FILE="$OPTARG" ;;
o) OUTDIR="$OPTARG" ;;
h) usage ;;
:) echo "Thiếu giá trị cho -$OPTARG" >&2; usage ;;
\?) echo "Option không hợp lệ: -$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
[[ -n "$HOSTS_FILE" ]] || usage
TMPDIR_RUN="$(mktemp -d)"
cleanup() { rm -rf -- "$TMPDIR_RUN"; }
trap cleanup EXIT
trap 'log "Lỗi tại dòng $LINENO: $BASH_COMMAND"' ERR
mkdir -p "$OUTDIR"
failed=()
while IFS= read -r host; do
[[ -z "$host" || "$host" == \#* ]] && continue
log "Backup $host"
if (( DRY_RUN )); then continue; fi
if ssh -n -o BatchMode=yes -o ConnectTimeout=10 "$host" 'show running-config' \
> "$TMPDIR_RUN/$host.cfg"; then
mv "$TMPDIR_RUN/$host.cfg" "$OUTDIR/$host-$(date +%F).cfg"
else
failed+=("$host")
fi
done < "$HOSTS_FILE"
if (( ${#failed[@]} )); then
log "Thất bại: ${failed[*]}"
exit 1
fissh trong vòng while read sẽ "nuốt" stdin và vòng lặp chỉ chạy 1 lần. Dùng ssh -n hoặc ssh ... < /dev/null.
Strict mode và lỗi
| Mục đích | Cú pháp |
|---|---|
| Dừng khi lệnh lỗi, biến chưa gán, lỗi trong pipe | set -euo pipefail |
| Debug từng lệnh | bash -x script.sh hoặc set -x / set +x |
| Cho phép một lệnh được lỗi | cmd || true |
Bắt lỗi mà không bị -e dừng |
if ! out=$(cmd 2>&1); then echo "$out"; fi |
| Dọn dẹp khi thoát (kể cả lỗi) | trap cleanup EXIT |
| Bắt Ctrl+C | trap 'echo dừng; exit 130' INT TERM |
| Biến bắt buộc, báo lỗi rõ | : "${TOKEN:?Chưa đặt TOKEN}" |
Biến và chuỗi
| Mục đích | Cú pháp |
|---|---|
| Giá trị mặc định | ${OUTDIR:-./out} |
| Độ dài chuỗi | ${#var} |
| Bỏ prefix ngắn nhất / dài nhất | ${path#*/} / ${path##*/} (= basename) |
| Bỏ suffix | ${file%.cfg} / ${path%/*} (= dirname) |
| Thay lần đầu / thay tất cả | ${s/eth/Gi} / ${s//,/ } |
| Cắt chuỗi | ${s:0:8} |
| Chữ thường / hoa | ${s,,} / ${s^^} |
| Regex và lấy group | [[ $ifname =~ ^([a-z]+)([0-9/]+)$ ]] && echo "${BASH_REMATCH[2]}" |
| So khớp glob | [[ $host == pe* ]] |
| Tách theo dấu phẩy | IFS=, read -r a b c <<< "$line" |
| Here-doc không expand biến | cat <<'EOF' ... EOF (nháy đơn quanh EOF) |
Mảng
| Mục đích | Cú pháp |
|---|---|
| Khai báo / thêm phần tử | hosts=(pe1 pe2) / hosts+=(pe3) |
| Duyệt an toàn | for h in "${hosts[@]}"; do ...; done |
| Số phần tử | ${#hosts[@]} |
| Đọc file thành mảng | mapfile -t hosts < hosts.txt |
| Mảng kết hợp | declare -A asn=([pe1]=64500 [pe2]=64501); echo "${asn[pe1]}" |
| Duyệt key của mảng kết hợp | for k in "${!asn[@]}"; do ...; done |
Vòng lặp và song song
| Mục đích | Cú pháp |
|---|---|
| Đọc file từng dòng | while IFS= read -r line; do ...; done < file |
| Dãy số | for i in {1..10} / for ((i=0; i<n; i++)) |
| Duyệt file có space trong tên | find . -name '*.cfg' -print0 | while IFS= read -r -d '' f; do ...; done |
| Chạy song song 10 luồng | xargs -P 10 -I{} ./backup-one.sh {} < hosts.txt |
| Chờ job nền | cmd1 & cmd2 & wait |
| Retry với backoff | for i in 1 2 3; do cmd && break; sleep $((i*5)); done |
| Giới hạn thời gian lệnh | timeout 30 cmd |
Kiểm tra
| Mục đích | Cú pháp |
|---|---|
| File tồn tại / thư mục / đọc được | [[ -f f ]] / [[ -d d ]] / [[ -r f ]] |
| Chuỗi rỗng / không rỗng | [[ -z $s ]] / [[ -n $s ]] |
| So sánh số | (( n > 5 )) |
| Lệnh có tồn tại | command -v jq >/dev/null || { echo "cần jq"; exit 1; } |
| Chạy với root | (( EUID == 0 )) || { echo "cần sudo"; exit 1; } |
Luôn đặt biến trong nháy kép ("$var", "${arr[@]}") để tránh tách từ và glob. Script dài hơn ~150 dòng hoặc cần parse JSON phức tạp thì chuyển sang Python. Xem thêm regex cheatsheet.