Mở đầu — vì sao bài này quan trọng
Nếu bạn hỏi mười DevOps Engineer đang đi làm rằng kỹ năng nào họ dùng hằng ngày nhưng hiếm khi được dạy bài bản, phần lớn sẽ trả lời: shell scripting. Trong bài trước, bạn đã làm quen với Linux — hệ điều hành chạy trên gần như mọi server production ở Việt Nam, từ VPS của các startup fintech cho tới cụm máy chủ của Tiki hay VNG. Nhưng biết dùng Linux mới chỉ là một nửa câu chuyện. Nửa còn lại là biết ra lệnh cho Linux tự làm việc thay mình — và đó chính là shell scripting.
Hãy hình dung một tình huống rất đời thường: mỗi sáng bạn phải SSH vào 5 server, kiểm tra dung lượng đĩa, xem service còn sống không, rồi backup log của ngày hôm trước. Làm tay thì mất 20 phút và dễ quên bước. Một script Bash 30 dòng biến 20 phút đó thành một lệnh duy nhất chạy trong 3 giây, chạy y hệt nhau mỗi lần, không mệt mỏi, không nhầm lẫn lúc 2 giờ sáng khi bị gọi dậy vì sự cố.
Đây là bản chất của DevOps: thay công sức lặp đi lặp lại của con người bằng automation. Và Bash là ngôn ngữ automation "cửa ngõ" — có sẵn trên mọi Linux server, không cần cài đặt gì, không cần dependency. Trước khi bạn học Terraform hay Ansible ở các bài sau, Bash là công cụ đầu tiên và đôi khi là duy nhất bạn có trong tay khi vừa SSH vào một máy chủ trắng tinh. Bài này sẽ dạy bạn viết script Bash đúng chuẩn production — không phải kiểu "chạy được là xong", mà kiểu an toàn, đọc được, và không làm sập hệ thống lúc nửa đêm.
Khái niệm cốt lõi
Vì sao lại là Bash?
Bash (Bourne Again SHell) là shell mặc định trên hầu hết bản phân phối Linux. Điểm mạnh lớn nhất của nó không phải là "ngôn ngữ đẹp" — thật ra cú pháp Bash khá xấu và nhiều cạm bẫy — mà là tính phổ biến tuyệt đối. Khi bạn SSH vào một server Ubuntu, CentOS, hay Amazon Linux vừa được provision, Python có thể chưa cài, Go chắc chắn chưa có, nhưng Bash luôn ở đó. Script đầu tiên của bạn không cần cài thêm bất cứ thứ gì.
Bash mạnh nhất khi bạn cần kết dính (glue) các câu lệnh Linux lại với nhau: chạy curl, lọc kết quả bằng grep, cắt cột bằng awk, rồi ghi vào file. Khi logic đơn giản và chủ yếu là gọi các công cụ CLI, Bash gọn hơn Python nhiều. Nhưng có một ranh giới cần nhớ: khi script vượt quá ~150–200 dòng, hoặc bắt đầu cần cấu trúc dữ liệu phức tạp (dictionary lồng nhau, JSON parsing nghiêm túc), hãy chuyển sang Python. Bash không sinh ra để làm việc đó, và cố ép nó sẽ tạo ra thứ code không ai bảo trì nổi.
Script template chuẩn production
Đây là khung sườn mà bạn nên bắt đầu cho mọi script nghiêm túc:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
Ba dòng này quan trọng đến mức đáng thuộc lòng:
#!/usr/bin/env bash— dòng shebang, báo cho hệ điều hành dùng Bash để chạy file. Dùng/usr/bin/env bashthay vì/bin/bashđể script tìm được Bash dù nó nằm ở đâu trongPATH(hữu ích trên macOS hay các hệ thống lạ).set -e— dừng script ngay khi có lệnh nào trả về lỗi (exit code khác 0). Nếu không có, script sẽ cứ chạy tiếp bất chấp lỗi — nguyên nhân số một của những sự cố kiểu "backup báo thành công nhưng thực ra rỗng".set -u— báo lỗi khi bạn dùng một biến chưa được khai báo. Cứu bạn khỏi thảm họa kinh điểnrm -rf "$DIR/"khi$DIRrỗng do gõ sai tên biến.set -o pipefail— làm cho pipelinea | b | ctrả về lỗi nếu bất kỳ lệnh nào trong chuỗi thất bại, không chỉ lệnh cuối.
set -euo pipefail được cộng đồng gọi vui là "unofficial strict mode" của Bash. Không có nó, script của bạn giống như lái xe không phanh — chạy được, cho đến khi không.Biến, tham số và cách "quote" cho đúng
Trong Bash, gán biến không có khoảng trắng quanh dấu =: name="Khang". Khi dùng, luôn bọc trong dấu ngoặc kép: echo "$name". Quy tắc vàng: luôn quote biến của bạn — "$var", không phải $var trần. Vì sao? Nếu biến chứa khoảng trắng (ví dụ đường dẫn /home/my folder/), việc không quote sẽ khiến Bash tách nó thành nhiều tham số riêng biệt, gây ra những bug cực kỳ khó tìm.
Tham số dòng lệnh: $1, $2... là các đối số truyền vào, $0 là tên script, $# là số lượng đối số, "$@" là toàn bộ đối số (nhớ quote!). Bạn nên kiểm tra đầu vào ngay đầu script:
if [[ $# -lt 1 ]]; then
echo "Cách dùng: $0 <tên_service>" >&2
exit 1
fi
service_name="$1"
Hàm, điều kiện và vòng lặp
Bash có đầy đủ cấu trúc lập trình cơ bản. Điều kiện dùng [[ ... ]] (cú pháp hiện đại, an toàn hơn [ ... ] cũ):
if [[ -f "/etc/nginx/nginx.conf" ]]; then
echo "File cấu hình tồn tại"
fi
Các phép kiểm tra hay dùng: -f (file tồn tại), -d (thư mục tồn tại), -z (chuỗi rỗng), -n (chuỗi khác rỗng), -eq/-lt/-gt (so sánh số).
Hàm giúp tách logic thành khối tái sử dụng, đặc biệt là hàm ghi log — thứ mọi script production nên có:
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
Tình huống thực tế
Tình huống 1 — Startup giao hàng và cái backup "giả"
Một startup logistics ở TP.HCM (tạm gọi là GiaoNhanh) chạy hệ thống trên một VPS Hostinger duy nhất trong giai đoạn đầu. Bạn dev viết một script backup database mỗi đêm:
#!/bin/bash
mysqldump -u root myapp > /backup/db.sql
gzip /backup/db.sql
aws s3 cp /backup/db.sql.gz s3://giaonhanh-backup/
Ba tháng trời cron chạy đều, log báo "thành công". Đến ngày ổ đĩa hỏng, họ mở file backup gần nhất trên S3 — rỗng 0 byte. Điều tra ra: mật khẩu MySQL đã đổi từ hai tháng trước, mysqldump thất bại và in ra file rỗng, nhưng vì không có set -e, script vẫn chạy tiếp gzip rồi s3 cp file rỗng đó lên, và cron ghi nhận exit code 0.
Bài học: Thiếu set -euo pipefail khiến lỗi bị nuốt chửng lặng lẽ. Chỉ cần dòng set -euo pipefail ở đầu và một bước kiểm tra if [[ ! -s "$file" ]]; then log "Backup rỗng!"; exit 1; fi là đã cứu được cả doanh nghiệp. Backup mà không verify thì không phải là backup — nó chỉ là niềm tin.
Tình huống 2 — Fintech và script dọn log lúc 2 giờ sáng
Một công ty fintech ở Hà Nội có script dọn log cũ để giải phóng đĩa:
LOG_DIR="/var/log/myapp"
rm -rf "$LOG_DIR/"*.log
Một hôm, ai đó refactor script và đổi tên biến thành LOGS_DIR ở phần khai báo, nhưng quên đổi ở dòng rm. Vì không có set -u, $LOG_DIR trở thành chuỗi rỗng, và lệnh biến thành rm -rf "/".log. May mắn là trong trường hợp này chỉ xóa file .log ở thư mục gốc, nhưng nếu là rm -rf "$LOG_DIR/" (không có .log) thì đã là rm -rf "/" — xóa sạch server.
Bài học: set -u biến lỗi im lặng chết người này thành lỗi báo ngay lập tức ("unbound variable"), dừng script trước khi nó gây hại. Ngoài ra, hãy tập thói quen dùng find "$LOG_DIR" -name '*.log' -mtime +7 -delete thay cho rm -rf mù quáng — an toàn hơn và có kiểm soát theo tuổi file.
Tình huống 3 — Đội SRE ở Grab và bài toán health check hàng loạt
Một team hạ tầng (bối cảnh tương tự các công ty như Grab hay Shopee ở Đông Nam Á) cần kiểm tra sức khỏe của 12 microservice mỗi 5 phút. Thay vì mở Grafana ngồi nhìn, một engineer viết một script Bash gọn gàng lặp qua danh sách endpoint, gọi curl, và bắn cảnh báo lên Telegram nếu service nào trả về mã khác 200:
services=("auth:8001" "payment:8002" "order:8003")
for svc in "${services[@]}"; do
name="${svc%%:*}"
port="${svc##*:}"
code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/health")
if [[ "$code" != "200" ]]; then
log "CẢNH BÁO: service $name trả về $code"
# gọi API Telegram bắn alert ở đây
fi
done
Bài học: Bash cực kỳ mạnh khi làm "chất keo" kết dính curl + vòng lặp + logic điều kiện. Script này chưa tới 20 dòng nhưng thay thế được một dashboard giám sát cơ bản trong giai đoạn team còn nhỏ. Đây đúng là "script đầu tiên không cần dependency" — chạy được ngay trên bất kỳ server nào có curl.
Hướng dẫn từng bước
Hãy cùng viết một script backup thực tế, an toàn, từ đầu đến cuối.
Bước 1 — Tạo file và cấp quyền chạy.
touch backup.sh
chmod +x backup.sh
Bước 2 — Dựng khung strict mode và khai báo biến.
#!/usr/bin/env bash
set -euo pipefail
DB_NAME="myapp"
BACKUP_DIR="/backup"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
Bước 3 — Viết hàm log để mọi dòng output đều có timestamp.
log() {
echo "[$(date '+%F %T')] $*"
}
Bước 4 — Kiểm tra điều kiện tiên quyết trước khi làm gì cả.
if [[ ! -d "$BACKUP_DIR" ]]; then
log "Thư mục backup không tồn tại, đang tạo..."
mkdir -p "$BACKUP_DIR"
fi
Bước 5 — Thực hiện backup và verify kết quả.
log "Bắt đầu backup database $DB_NAME"
mysqldump "$DB_NAME" | gzip > "$BACKUP_FILE"
if [[ ! -s "$BACKUP_FILE" ]]; then
log "LỖI: file backup rỗng, hủy bỏ"
exit 1
fi
log "Backup thành công: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
Bước 6 — Dọn backup cũ hơn 7 ngày.
find "$BACKUP_DIR" -name '*.sql.gz' -mtime +7 -delete
log "Đã dọn backup cũ hơn 7 ngày"
Bước 7 — Chạy thử và kiểm tra cú pháp. Trước khi đưa vào cron, luôn chạy bash -n backup.sh để kiểm tra lỗi cú pháp, và quan trọng nhất: chạy nó qua ShellCheck (shellcheck backup.sh) — công cụ phân tích tĩnh sẽ bắt hàng loạt lỗi tiềm ẩn mà mắt thường bỏ sót.
Lỗi thường gặp & mẹo
- Quên quote biến.
cp $src $dstsẽ vỡ ngay khi đường dẫn có khoảng trắng. Luôn viếtcp "$src" "$dst". Đây là lỗi phổ biến nhất và nguy hiểm nhất. - Bỏ qua strict mode. Không có
set -euo pipefail, script của bạn sẽ nuốt lỗi âm thầm như tình huống 1. Đưa nó vào ngay dòng đầu, mọi lúc. - So sánh chuỗi bằng
=với[[ ]]nhưng số bằng-eq. Nhầm lẫn[[ "$a" == "$b" ]](chuỗi) với[[ "$a" -eq "$b" ]](số) gây kết quả sai khó hiểu. - Không dùng ShellCheck. Đây là mẹo giá trị nhất của cả bài: cài
shellcheckvà tích hợp vào editor hoặc CI. Nó bắt lỗi quote, biến chưa khai báo, cú pháp sai... trước khi chúng lên production. Coi như có một mentor Bash ngồi review cùng bạn 24/7. - Xử lý lỗi bằng
trap. Với script phức tạp, dùngtrap 'log "Script thất bại ở dòng $LINENO"' ERRđể có thông báo lỗi rõ ràng, vàtrap 'rm -f "$tmpfile"' EXITđể dọn dẹp file tạm dù script kết thúc thế nào. - Dùng
mktempcho file tạm thay vì tự đặt tên/tmp/mytemp— tránh xung đột và lỗ hổng bảo mật. - Biết khi nào dừng dùng Bash. Nếu bạn thấy mình parse JSON bằng
grep/sed, hoặc script đã hơn 200 dòng với logic lồng nhau — đó là tín hiệu chuyển sang Python.
Bài tập thực hành
- Script cơ bản: Viết một script
disk_check.shnhận vào một ngưỡng phần trăm (ví dụ./disk_check.sh 80), kiểm tra dung lượng đĩa của/, và in cảnh báo nếu vượt ngưỡng. Bắt buộc dùngset -euo pipefailvà kiểm tra đối số đầu vào.
- Vòng lặp và hàm: Mở rộng script health check ở Tình huống 3 để đọc danh sách service từ một file text (mỗi dòng một
tên:port), lặp qua từng dòng, và in ra bảng trạng thái. Thêm hàmlogcó timestamp.
- An toàn hóa: Lấy đoạn script backup "giả" ở Tình huống 1, viết lại cho an toàn: thêm strict mode, verify file không rỗng, và thêm
trapđể báo lỗi kèm số dòng. Sau đó chạyshellcheckvà sửa mọi cảnh báo nó đưa ra.
- Thử thách: Viết script tự động backup rồi lên lịch chạy hằng đêm qua
cron(dùngcrontab -e). Ghi output của mỗi lần chạy vào file log để có thể kiểm tra lại sau này.
Tóm tắt
Bash là ngôn ngữ automation "cửa ngõ" của DevOps: có sẵn trên mọi Linux server, không cần dependency, và mạnh nhất khi kết dính các câu lệnh CLI lại với nhau. Điều quan trọng nhất bạn cần mang theo từ bài này không phải là cú pháp — mà là kỷ luật viết script an toàn: luôn bắt đầu bằng set -euo pipefail, luôn quote biến, luôn verify kết quả trước khi tin nó thành công, và luôn chạy qua ShellCheck. Ba tình huống thực tế cho thấy khoảng cách giữa một script "chạy được" và một script "production-grade" chính là những chi tiết an toàn nhỏ bé đó — chúng là ranh giới giữa một đêm ngủ yên và một cuộc gọi khẩn cấp lúc 2 giờ sáng. Hãy nhớ giới hạn của Bash: khi script phình to hoặc cần xử lý dữ liệu phức tạp, đó là lúc chuyển sang Python. Nắm vững Bash, bạn đã có công cụ tự động hóa đầu tiên và nền tảng tư duy automation cho toàn bộ hành trình DevOps phía trước.