Featured image of post Function trong Bash: tái sử dụng code hiệu quả cho DevOps

Function trong Bash: tái sử dụng code hiệu quả cho DevOps

Hướng dẫn viết function trong Bash: cú pháp, tham số, biến local, return code, source file khác và xây dựng thư viện logging tái sử dụng cho script DevOps.

Function trong Bash cho DevOps

bài trước chúng ta đã dùng grep, awksed để xử lý log/config. Khi script bắt đầu dài hơn, bạn sẽ gặp một vấn đề mới: nhiều đoạn code bị lặp lại — kiểm tra file, ghi log, validate biến môi trường, retry command, tạo backup trước khi sửa config.

Function giúp gom một nhóm lệnh thành một khối có tên, có thể gọi lại nhiều lần. Với DevOps, đây là bước quan trọng để biến các script “chạy được” thành script dễ đọc, dễ test và dễ bảo trì hơn.


Khai báo function trong Bash

Bash hỗ trợ hai kiểu khai báo phổ biến:

1
2
3
4
5
6
7
log_info() {
  echo "INFO: $1"
}

function log_error {
  echo "ERROR: $1" >&2
}

Trong thực tế, kiểu name() { ...; } thường được dùng nhiều vì ngắn gọn và portable hơn giữa các shell kiểu POSIX. Với bài này, chúng ta tập trung vào Bash nên cả hai đều chạy được.

Gọi function giống gọi command:

1
2
log_info "Starting deploy"
log_error "Deploy failed"

Lưu ý khoảng trắng và dấu ; khi viết một dòng:

1
say_hello() { echo "hello"; }

Nếu viết nhiều dòng, dấu ; trước } không cần thiết vì newline đã kết thúc command.


Function nhận tham số bằng $1, $@, $#

Trong function, Bash dùng các positional parameters giống script:

  • $1, $2, …: tham số thứ nhất, thứ hai, …
  • $@: toàn bộ tham số, giữ từng argument riêng khi được quote thành "$@".
  • $#: số lượng tham số.
  • $0: tên script, không phải tên function.

Ví dụ function kiểm tra service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
check_service() {
  local service_name="$1"

  if systemctl is-active --quiet "${service_name}"; then
    echo "OK: ${service_name} is running"
  else
    echo "ERROR: ${service_name} is not running" >&2
    return 1
  fi
}

check_service "nginx"
check_service "docker"

Khi cần truyền toàn bộ argument sang command khác, dùng "$@":

1
2
3
4
5
6
run_cmd() {
  echo "+ $*"
  "$@"
}

run_cmd ls -lah /var/log

"$@" giữ nguyên ranh giới argument. Đây là lựa chọn an toàn hơn so với $* khi tham số có khoảng trắng.


Validate tham số đầu vào

Function nên kiểm tra tham số bắt buộc ngay từ đầu để lỗi rõ ràng hơn.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
require_arg() {
  local name="$1"
  local value="${2:-}"

  if [[ -z "${value}" ]]; then
    echo "ERROR: Missing required argument: ${name}" >&2
    return 2
  fi
}

backup_file() {
  local file_path="${1:-}"

  require_arg "file_path" "${file_path}" || return $?

  if [[ ! -f "${file_path}" ]]; then
    echo "ERROR: Not a regular file: ${file_path}" >&2
    return 1
  fi

  cp -- "${file_path}" "${file_path}.bak.$(date +%Y%m%d%H%M%S)"
}

Dùng ${1:-} giúp tránh lỗi “unbound variable” khi script bật set -u mà function được gọi thiếu tham số.


Return value trong Bash là exit code

Function Bash không return dữ liệu dạng string như nhiều ngôn ngữ lập trình. return trong Bash trả về exit code từ 0 đến 255:

  • 0: thành công.
  • Khác 0: lỗi hoặc trạng thái đặc biệt.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
is_readable_file() {
  local file_path="$1"
  [[ -f "${file_path}" && -r "${file_path}" ]]
}

if is_readable_file "./app.env"; then
  echo "app.env is readable"
else
  echo "Cannot read app.env" >&2
fi

Ở ví dụ trên, function không cần viết return rõ ràng. Exit code của lệnh cuối cùng ([[ ... ]]) sẽ trở thành exit code của function.

Nếu muốn function “trả về dữ liệu”, hãy in ra stdout rồi dùng command substitution:

1
2
3
4
5
get_timestamp() {
  date +%Y%m%d%H%M%S
}

backup_name="app.env.$(get_timestamp).bak"

Nguyên tắc thực dụng: dùng exit code cho đúng/sai, dùng stdout cho dữ liệu.


Biến local để tránh side effect

Theo mặc định, biến trong function Bash là global trong phạm vi shell hiện tại. Dùng local để giới hạn biến trong function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
build_backup_path() {
  local source_file="$1"
  local timestamp
  local base_name

  timestamp="$(date +%Y%m%d%H%M%S)"
  base_name="$(basename "${source_file}")"

  echo "./backup/${base_name}.${timestamp}.bak"
}

Không dùng local, các biến như timestamp hoặc base_name có thể vô tình ghi đè biến cùng tên ở nơi khác trong script. Đây là lỗi khó debug khi script lớn dần.

Một thói quen tốt là khai báo local gần nơi dùng và quote biến khi truyền vào command:

1
2
local target_dir="${1:-./backup}"
mkdir -p "${target_dir}"

Tách function vào file khác với source

Khi nhiều script cùng cần logging, validation hoặc retry, bạn có thể tách function vào file thư viện nhỏ.

Ví dụ cấu trúc:

1
2
3
4
scripts/
├── deploy.sh
└── lib/
    └── log.sh

File scripts/lib/log.sh:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/env bash

log_info() {
  printf '[INFO] %s\n' "$*"
}

log_warn() {
  printf '[WARN] %s\n' "$*" >&2
}

log_error() {
  printf '[ERROR] %s\n' "$*" >&2
}

File scripts/deploy.sh:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/log.sh"

log_info "Starting deploy"
log_warn "This is a warning message"
log_error "This is an error message"

source chạy nội dung file trong shell hiện tại, nên các function trong log.sh sẽ có sẵn cho deploy.sh. Trong Bash, ${BASH_SOURCE[0]} giúp xác định đường dẫn file script hiện tại tốt hơn $0 khi file được source hoặc gọi từ thư mục khác.


Thực hành DevOps: thư viện logging nhỏ

Một thư viện logging tối thiểu nên có timestamp, level và tách stdout/stderr hợp lý.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env bash

_log() {
  local level="$1"
  shift

  printf '%s [%s] %s\n' "$(date -Is)" "${level}" "$*"
}

log_info() {
  _log "INFO" "$@"
}

log_warn() {
  _log "WARN" "$@" >&2
}

log_error() {
  _log "ERROR" "$@" >&2
}

Đặt function nội bộ tên _log là convention đơn giản để báo hiệu “không gọi trực tiếp từ bên ngoài”. Bash không có private function thật sự, nên đây chỉ là quy ước.

Dùng trong script healthcheck:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/log.sh"

check_url() {
  local url="$1"

  if curl --fail --silent --show-error --max-time 5 "${url}" > /dev/null; then
    log_info "Healthcheck OK: ${url}"
  else
    log_error "Healthcheck failed: ${url}"
    return 1
  fi
}

check_url "https://example.com/health"

Ở đây curl --fail giúp HTTP 4xx/5xx được xem là lỗi, --silent --show-error giảm noise nhưng vẫn in lỗi cần thiết, và --max-time tránh script treo quá lâu.


Thực hành DevOps: retry command bằng function

Retry là pattern rất thường gặp khi gọi API, pull image, kiểm tra service vừa restart hoặc chạy lệnh có thể lỗi tạm thời.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
retry() {
  local max_attempts="$1"
  local delay_seconds="$2"
  shift 2

  local attempt=1

  while (( attempt <= max_attempts )); do
    if "$@"; then
      return 0
    fi

    echo "WARN: attempt ${attempt}/${max_attempts} failed: $*" >&2

    if (( attempt == max_attempts )); then
      return 1
    fi

    sleep "${delay_seconds}"
    ((attempt++))
  done
}

retry 5 3 curl --fail --silent --show-error https://example.com/health

Điểm quan trọng:

  • shift 2 bỏ hai tham số cấu hình để phần còn lại là command cần chạy.
  • "$@" chạy command với argument được giữ nguyên.
  • Function trả 0 ngay khi command thành công, trả 1 sau khi hết số lần thử.

Bạn có thể kết hợp với logging library:

1
2
3
4
5
6
if retry 5 3 curl --fail --silent --show-error https://example.com/health; then
  log_info "Service is healthy"
else
  log_error "Service is still unhealthy after retry"
  exit 1
fi

Thực hành DevOps: deploy script có function rõ ràng

Ví dụ script deploy nhỏ, tách từng bước thành function để dễ đọc hơn:

 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
#!/usr/bin/env bash
set -euo pipefail

APP_DIR="${APP_DIR:-/opt/example-app}"
SERVICE_NAME="${SERVICE_NAME:-example-app}"

log_info() {
  printf '%s [INFO] %s\n' "$(date -Is)" "$*"
}

log_error() {
  printf '%s [ERROR] %s\n' "$(date -Is)" "$*" >&2
}

require_dir() {
  local dir_path="$1"

  if [[ ! -d "${dir_path}" ]]; then
    log_error "Directory not found: ${dir_path}"
    return 1
  fi
}

pull_latest_code() {
  git -C "${APP_DIR}" pull --ff-only
}

restart_service() {
  sudo systemctl restart "${SERVICE_NAME}"
}

verify_service() {
  systemctl is-active --quiet "${SERVICE_NAME}"
}

main() {
  require_dir "${APP_DIR}"
  log_info "Pulling latest code"
  pull_latest_code

  log_info "Restarting ${SERVICE_NAME}"
  restart_service

  log_info "Verifying ${SERVICE_NAME}"
  if verify_service; then
    log_info "Deploy completed"
  else
    log_error "Service is not active after restart"
    return 1
  fi
}

main "$@"

Pattern main "$@" giúp script có entrypoint rõ ràng. Các function phía trên định nghĩa hành vi, còn main mô tả luồng chạy chính.


Sai sót thường gặp

  • Quên local: Biến trong function có thể ghi đè biến global ngoài ý muốn.
  • Dùng return "text": return chỉ dành cho exit code số; muốn trả text thì echo/printf ra stdout.
  • Không quote "$@": Command wrapper dễ hỏng khi argument có khoảng trắng.
  • Gọi function trước khi khai báo: Bash đọc và chạy từ trên xuống; function phải được định nghĩa trước khi gọi.
  • Dùng $1 trực tiếp khi bật set -u: Nếu thiếu tham số, script lỗi ngay. Dùng ${1:-} rồi validate.
  • Ghi log lỗi ra stdout: Error/warn nên đi stderr (>&2) để stdout còn dùng cho dữ liệu/pipeline.

Ghi chú triển khai

  • Khi áp dụng vào dự án của bạn, hãy bắt đầu bằng các function nhỏ cho phần lặp lại nhiều nhất:
    • log_info, log_warn, log_error.
    • require_file, require_dir, require_env.
    • retry cho lệnh có lỗi tạm thời.
    • backup_file trước khi sửa config.
  • Best practices:
    • Dùng local cho biến trong function.
    • Dùng return cho trạng thái thành công/thất bại, stdout cho dữ liệu.
    • Dùng "$@" khi wrapper function gọi command khác.
    • Tách thư viện chung vào scripts/lib/*.sh và load bằng source với đường dẫn dựa trên ${BASH_SOURCE[0]}.
    • Giữ function làm một việc rõ ràng; nếu function quá dài, tách tiếp.
  • Troubleshooting:
    • Function trả sai trạng thái? → In thử $? ngay sau khi gọi hoặc chạy bash -x script.sh.
    • Biến bị đổi bất ngờ? → Kiểm tra function thiếu local.
    • source không tìm thấy file? → Kiểm tra SCRIPT_DIR và chạy script từ thư mục khác để test.

🎯 Lời kết

Function là bước nâng cấp tự nhiên khi bạn viết Bash cho DevOps nhiều hơn một vài dòng. Chúng giúp tái sử dụng logic, gom logging/validation/retry vào nơi chung, giảm copy-paste và làm luồng deploy/healthcheck rõ ràng hơn.

Ở bài tiếp theo, chúng ta sẽ đi vào quản lý biến và môi trường trong Bash: biến local vs export, .env, getopts, $IFS và các biến đặc biệt như $?, $$, $!. 🚀


Tài liệu tham khảo