Bash 错误处理与日志记录
与任何软件一样,Bash 脚本在执行过程中也会遇到错误。有效地处理这些错误并记录脚本的运行活动,对于创建可靠且易于维护的自动化工具相当重要。健壮的脚本能够预判潜在问题,优雅地应对失败,并为故障排查和审计提供清晰的记录。
1. 理解退出状态码 (Exit Codes)
在 Bash 中执行的每一个命令都会返回一个退出状态码(也叫退出码),这是一个数字,用来指示命令是执行成功还是失败。按照惯例,退出状态码为 0 表示成功,而任何非零值都表示发生了错误。不同的非零值通常代表特定类型的错误,不过具体的含义往往取决于命令本身。
要获取刚刚执行的命令的退出状态码,你可以使用特殊变量 $?。
# 示例 1:成功的命令
ls /tmp
echo "'ls /tmp' 的退出状态码是: $?" # 输出: 0 (假设 /tmp 目录存在)
# 示例 2:失败的命令
ls /nonexistent_directory
echo "'ls /nonexistent_directory' 的退出状态码是: $?" # 输出: 1 或 2 (取决于系统和 ls 版本)
# 示例 3:一个返回自定义退出状态码的简单脚本
#!/bin/bash
# script.sh
echo "正在执行操作..."
exit 10 # 脚本以状态码 10 退出
# 执行脚本并检查其退出状态码
./script.sh
echo "'script.sh' 的退出状态码是: $?" # 输出: 101.1 退出状态码的影响
脚本通常会将多个命令串联在一起执行。如果调用链中的某个命令失败了,后续的命令可能会在无效的数据或错误的假设下继续运行,从而导致更严重的错误或异常行为。通过检查退出状态码,脚本可以对这些失败做出反应,防止问题像滚雪球一样恶化。
假设这样一个场景:脚本需要下载一个文件,然后尝试对其进行处理。如果下载失败,处理步骤很可能会报错或者产生垃圾数据。
#!/bin/bash
# download_and_process.sh
DOWNLOAD_URL="http://example.com/some_file.zip" # 替换为真实 URL 或用于测试失败的假 URL
LOCAL_FILE="downloaded_file.zip"
echo "尝试下载 $DOWNLOAD_URL..."
wget -q "$DOWNLOAD_URL" -O "$LOCAL_FILE"
# 检查 wget 命令的退出状态码
if [ $? -ne 0 ]; then
echo "错误:下载 $DOWNLOAD_URL 失败"
exit 1 # 以错误状态码退出脚本
fi
echo "下载成功。正在处理文件..."
# 模拟处理过程 (例如:解压,然后检查是否成功)
unzip -q "$LOCAL_FILE" -d "extracted_data"
if [ $? -ne 0 ]; then
echo "错误:文件解压失败。"
rm -f "$LOCAL_FILE" # 清理下载不完整的文件
exit 2 # 以不同的错误状态码退出脚本
fi
echo "文件处理成功。正在清理。"
rm -f "$LOCAL_FILE"
rm -rf "extracted_data"
exit 0 # 成功退出这个脚本演示了如何检查 wget 的退出状态码。如果 wget 失败(例如:URL 错误,网络故障),脚本会打印错误信息并立即以状态码 1 退出,从而阻止了后续对一个不存在或不完整的文件执行 unzip 命令。
2. set -e:遇到错误立即退出
在每个命令后手动检查 $? 会让脚本变得冗长且难以阅读。set -e 选项是 Bash 中一个非常强大的功能,它会在任何命令失败(即返回非零退出状态码)时,自动且立即地退出脚本。这有助于防止部分执行的命令带来意外后果。
#!/bin/bash
# script_with_set_e.sh
set -e # 如果命令以非零状态退出,则立即终止脚本。
echo "步骤 1: 创建目录..."
mkdir my_temp_dir
echo "步骤 2: 进入目录..."
cd my_temp_dir
echo "步骤 3: 创建文件..."
touch new_file.txt
echo "步骤 4: 尝试执行一个注定会失败的命令..."
# 此命令会失败,因为 /nonexistent_path 不存在
cp new_file.txt /nonexistent_path/
echo "如果 cp 命令失败,这行代码将不会被执行。"
echo "步骤 5: 清理..."
# 如果 cp 失败,这些清理命令也不会执行
cd ..
rm -rf my_temp_dir
echo "脚本成功完成 (如果 cp 失败,你不应该看到这句话)。"当你运行 script_with_set_e.sh 时,脚本会创建 my_temp_dir,进入该目录,创建 new_file.txt,然后在 cp new_file.txt /nonexistent_path/ 失败时立即退出。随后的 echo "如果 cp 命令失败..." 和清理命令都将被跳过。
2.1 set -e 的例外情况
set -e 非常有用,但在某些特定条件下,它不会导致脚本退出:
- 条件语句(if/while)中的命令: 如果命令是
if或while条件的一部分,它的失败不会触发set -e,因为if/while语句本身就是用来处理该命令成功或失败逻辑的。
set -e
if ! ls /nonexistent_dir; then # ls 失败了,但 set -e 不会被触发
echo "已处理目录不存在的情况。"
fi
echo "处理完目录不存在的情况后,脚本继续执行。"&&或||列表中的命令: 只有在&&或||列表中的最后一个命令失败时,才会触发set -e。
set -e
# 第一个 `false` 命令失败了,但由于后面的 `true` 运行成功,整个 `||` 结构被认为是 "成功" 的。
# 脚本在这里不会退出。
false || true
echo "在 false || true 之后,脚本继续执行。"
# 在这里,`false` 是 `&&` 列表中的最后一个命令,并且它失败了。
# 脚本将会在这里退出。
true && false
echo "这行代码将不会被执行。"返回值被显式忽略的命令: 例如,如果一个命令的输出被重定向到了 /dev/null,或者它的结果通过命令替换 $(command) 赋值给了一个变量。
set -e
# 命令替换:ls /nonexistent_dir 失败了,但它的退出状态码被忽略了,
# 因为它的输出正在被捕获。脚本不会退出。
RESULT=$(ls /nonexistent_dir 2>&1)
echo "失败命令的结果: $RESULT"
echo "在命令替换之后,脚本继续执行。"管道中的最后一个命令: 如果管道中的某个命令(除了最后一个)失败了,set -e 不会触发。只有管道中最后一个命令的退出状态,才决定了整个管道的退出状态。想让 set -e 对管道中任何失败的命令都做出反应,你需要使用 set -o pipefail。
3. set -o pipefail:增强管道命令的错误处理
默认情况下,Bash 中的管道 (command1 | command2 | command3) 会返回管道中最后一个命令的退出状态。这可能会掩盖之前命令的错误。例如,如果 command1 失败了,但 command2(可能不需要输入,或者能很好地处理空输入)成功了,整个管道依然会报告成功。
set -o pipefail 选项(通常与 set -e 结合使用,写成 set -e -o pipefail 或 set -euo pipefail)改变了这种行为。启用 pipefail 后,管道的退出状态将是管道中最后一个以非零状态退出的命令的状态。如果所有命令都成功,管道的退出状态才是 0。这意味着,如果管道中的任何命令失败,整个管道都会被视为失败,随之 set -e 就会让脚本安全退出。
#!/bin/bash
# script_with_pipefail.sh
# 遇到错误退出,并启用 pipefail
set -euo pipefail
echo "演示 pipefail 的作用..."
# 示例 1: `cat` 失败,`grep` 在处理空输入时成功。如果不使用 pipefail,这个管道会成功。
# 使用了 pipefail,它将失败,因为 `cat` 失败了。
echo "--- 管道 1 (cat /nonexistent | grep pattern) ---"
cat /nonexistent_file.txt | grep "pattern" # `cat` 将失败,`grep` 因为没有匹配项将返回 0。
echo "如果启用了 pipefail 且 cat 失败,这行代码将不会被执行。"
echo "--- 管道 2 (echo valid | grep -q pattern) ---"
# 这个管道会成功。
echo "some text" | grep -q "text"
echo "这行代码将会被执行,因为管道 2 成功了。"
echo "--- 管道 3 (echo valid | grep -q missing) ---"
# `grep` 失败 (返回 1),因为没有找到 "missing"。
# 脚本将在这里退出,因为 `grep` 是最后一个命令并且失败了。
echo "some text" | grep -q "missing"
echo "这行代码将不会被执行,因为管道 3 失败了。"
echo "脚本完成 (你不应该看到这句话)。"运行 script_with_pipefail.sh 时,cat /nonexistent_file.txt 会失败,并且因为启用了 set -o pipefail,脚本会在此管道执行完毕后立即退出,阻止后续的 echo 语句运行。
4. 使用 trap 命令进行清理
trap 命令允许你在脚本接收到特定信号时,执行一段命令或函数。这对于清理操作(例如删除临时文件)来说是无价之宝,即使脚本因为错误、用户中断 (Ctrl+C) 或其他信号意外退出,也能保证清理工作顺利进行。
常见的可捕获信号包括:
- EXIT: 在脚本退出时执行,无论脚本是成功还是失败。
- ERR: 在命令以非零状态退出时执行(如果启用了
set -e,ERR会在EXIT之前被触发)。 - INT: 当脚本收到中断信号(例如按下
Ctrl+C)时执行。 - TERM: 当脚本收到终止信号(例如通过
kill <pid>命令)时执行。
#!/bin/bash
# trap_example.sh
set -euo pipefail
TEMP_DIR=$(mktemp -d) # 创建一个临时目录
# 定义一个清理函数
cleanup() {
echo "捕获到信号!正在执行清理..."
if [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
echo "已删除临时目录: $TEMP_DIR"
fi
}
# 注册清理函数,使其在 EXIT, ERR, INT, TERM 信号发生时被调用
trap cleanup EXIT
trap cleanup ERR
trap cleanup INT
trap cleanup TERM
echo "已创建临时目录: $TEMP_DIR"
touch "$TEMP_DIR/my_temp_file.txt"
echo "已创建临时文件: $TEMP_DIR/my_temp_file.txt"
# 模拟一些工作耗时
echo "正在执行重要任务,耗时 3 秒..."
sleep 3
# 模拟一个错误 (这将会依次触发 ERR 和 EXIT 陷阱)
echo "模拟发生错误..."
non_existent_command # 这个命令会失败
echo "因为 'set -e' 和模拟的错误,这行代码不会被执行。"
# EXIT 陷阱始终会在最后运行,无论是否发生错误。运行 trap_example.sh。当 non_existent_command 执行时,set -e 会导致脚本退出。在退出之前,ERR 陷阱会被触发并调用 cleanup 函数。紧接着,EXIT 陷阱也会被触发,再次调用 cleanup。如果你在 sleep 3 执行期间按下 Ctrl+C,INT 陷阱会被触发,在脚本终止前调用 cleanup。
4.1 trap 配合函数 vs 内联命令
虽然你可以在 trap 中直接写入内联命令,但对于复杂的清理工作,定义一个专属函数会让脚本更加可读且易于维护。
# 内联 trap 示例 (对于复杂任务来说可读性较差)
trap 'echo "脚本退出。正在删除 $TEMP_FILE"; rm -f "$TEMP_FILE"' EXIT5. 记录脚本活动 (Logging)
日志记录提供了脚本执行的历史轨迹,这对于调试、审计和监控至关重要。将输出重定向到日志文件,而不是仅仅将消息 echo 到控制台,可以确保重要的运行信息被妥善保存,供日后查阅。
5.1 基础的日志写入文件
最简单的日志记录形式是将脚本的标准输出和标准错误重定向到一个文件。
#!/bin/bash
# basic_logger.sh
LOG_FILE="/var/log/my_script.log" # 或者用户自定义的路径,如 /tmp 目录下
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
# 将所有脚本输出 (stdout 和 stderr) 重定向并追加到日志文件
exec > >(tee -a "$LOG_FILE") 2>&1
echo "[$TIMESTAMP] 脚本已启动。"
echo "[$TIMESTAMP] 正在执行任务 1..."
# 模拟一个成功的命令
ls /tmp
echo "[$TIMESTAMP] 正在执行任务 2..."
# 模拟一个可能失败的命令
# cp /nonexistent_source /tmp/destination
echo "[$TIMESTAMP] 脚本执行完毕。"运行 basic_logger.sh 时,所有的 echo 语句,以及 ls 和 cp 产生的输出(或报错),都将同时输出到控制台和指定的 LOG_FILE 中。exec > >(tee -a "$LOG_FILE") 2>&1 这个强大的命令做了以下几件事:
exec >(...): 将整个脚本的标准输出重定向到进程替换(...)中。tee -a "$LOG_FILE":tee命令读取标准输入,并将其同时写入标准输出和指定的文件,-a参数表示以追加(append)模式写入。2>&1: 将标准错误(文件描述符 2)重定向到与标准输出(文件描述符 1)相同的位置,确保错误消息也能被记录进日志。
5.2 使用函数实现结构化日志
为了获得更规范的日志,你可以创建一个日志专用的函数,为每条消息加上时间戳、日志级别(INFO, WARN, ERROR)前缀,甚至加上脚本名称或进程 ID (PID)。
#!/bin/bash
# structured_logger.sh
LOG_FILE="/var/log/my_structured_script.log"
SCRIPT_NAME=$(basename "$0")
PID=$$ # 脚本的进程 ID
# 确保日志文件存在并且可写
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE" || { echo "错误:无法创建日志文件 $LOG_FILE。退出。" >&2; exit 1; }
# 核心日志记录函数
log_message() {
local LEVEL="$1"
local MESSAGE="$2"
local TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "[$TIMESTAMP] [$SCRIPT_NAME:$PID] [$LEVEL] $MESSAGE" | tee -a "$LOG_FILE"
}
# 针对不同日志级别的封装函数
log_info() {
log_message "INFO" "$1"
}
log_warn() {
log_message "WARN" "$1" >&2 # 将警告信息同时发送到 stderr
}
log_error() {
log_message "ERROR" "$1" >&2 # 将错误信息同时发送到 stderr
exit 1 # 默认在遇到 ERROR 时退出脚本
}
# --- 主脚本逻辑 ---
log_info "脚本已启动。"
TEMP_DIR=$(mktemp -d)
log_info "已创建临时目录: $TEMP_DIR"
# 定义使用 trap 的清理函数
cleanup() {
local exit_status=$? # 在 log_error 或其他命令改变 $? 之前捕获真实的退出状态码
log_info "正在清理临时目录: $TEMP_DIR"
if [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
log_info "已删除临时目录: $TEMP_DIR"
fi
exit "$exit_status" # 确保脚本以原始的状态码彻底退出
}
trap cleanup EXIT
log_info "在 $TEMP_DIR 中创建一些文件..."
touch "$TEMP_DIR/file1.txt" "$TEMP_DIR/file2.log"
if [ $? -ne 0 ]; then
log_error "在 $TEMP_DIR 中创建文件失败。"
fi
log_info "文件创建成功。"
# 模拟一个警告
log_warn "潜在问题:磁盘空间使用率偏高。"
# 模拟一个错误 (你可以取消注释来测试)
# log_error "连接数据库失败。"
log_info "脚本执行完毕。"
# EXIT 陷阱将处理最终的清理工作并退出。在 structured_logger.sh 中,日志消息的格式包含了时间戳、脚本名、PID 和日志级别。tee -a "$LOG_FILE" 确保了控制台和日志文件都能看到消息。log_error 会直接终止脚本,为致命故障提供了一个干净利落的退出点。
5.3 日志轮转 (Log Rotation)
随着时间推移,日志文件可能会变得非常庞大,占用大量磁盘空间并且难以查阅。日志轮转是将旧日志归档、压缩并最终删除以保持系统整洁的过程。虽然 Bash 本身没有内置这个功能,但在 Linux 系统中,这通常由 logrotate 等系统实用程序来处理。
要与 logrotate 集成,你的脚本只需要持续向一个固定的日志文件路径写入即可。logrotate 会读取它的配置文件(例如存放在 /etc/logrotate.d/你的脚本名 中),然后接管该文件的管理工作。
/etc/logrotate.d/my_structured_script 配置文件示例:
/var/log/my_structured_script.log {
daily # 每天轮转一次
missingok # 如果日志文件丢失,不报错
rotate 7 # 保留 7 份旧日志文件
compress # 压缩旧日志文件
notifempty # 如果日志为空则不进行轮转
create 0640 root adm # 以指定的权限和所有者创建新的日志文件
postrotate # 轮转后执行的命令
# 如果你的脚本需要接收信号来重新打开它的日志文件,可以写在这里。
# 比如向日志进程发送 SIGHUP。
# 对于仅仅使用 append (追加) 模式的简单 Bash 脚本来说,这通常是不需要的,
# 因为下一次写入操作自然会往新的同名文件里写。
endscript
}6. 实战案例:增强系统管理脚本
让我们将这些错误处理和日志记录技术应用到我们的日常系统管理脚本中。假设我们的脚本旨在执行备份、更新软件包和清理临时文件。我们需要确保如果任何一个步骤失败,脚本能优雅地退出,并且保留详尽的日志轨迹。
6.1 原始的系统管理脚本概念(未强化版)
#!/bin/bash
# sys_admin_script_v1.sh
echo "开始执行系统管理任务。"
echo "正在运行备份..."
# 模拟备份命令
# rsync -avz /data /mnt/backup_drive/
echo "正在更新软件包..."
sudo apt update && sudo apt upgrade -y
echo "正在清理临时文件..."
sudo rm -rf /tmp/*
echo "系统管理任务完成。"6.2 包含错误处理和日志记录的增强版脚本
#!/bin/bash
# sys_admin_script_v2_robust.sh
# --- 配置区 ---
LOG_FILE="/var/log/sys_admin_script.log"
BACKUP_SOURCE="/var/www/html" # 示例备份源目录
BACKUP_DEST="/mnt/backup_drive/web_data_$(date +%Y%m%d%H%M%S)" # 示例目标目录
# --- 脚本初始化 ---
set -euo pipefail # 遇到错误即退出,同时约束未定义变量和管道失败
SCRIPT_NAME=$(basename "$0")
PID=$$
# 确保日志目录存在,且日志文件可被创建
mkdir -p "$(dirname "$LOG_FILE")" || { echo "错误:无法为 $LOG_FILE 创建日志目录。退出。" >&2; exit 1; }
touch "$LOG_FILE" || { echo "错误:无法创建日志文件 $LOG_FILE。退出。" >&2; exit 1; }
# --- 日志函数封装 ---
log_message() {
local LEVEL="$1"
local MESSAGE="$2"
local TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
# 输出到控制台 (错误/警告送到 stderr,信息送到 stdout) 并追加到日志文件
if [[ "$LEVEL" == "ERROR" || "$LEVEL" == "WARN" ]]; then
echo "[$TIMESTAMP] [$SCRIPT_NAME:$PID] [$LEVEL] $MESSAGE" | tee -a "$LOG_FILE" >&2
else
echo "[$TIMESTAMP] [$SCRIPT_NAME:$PID] [$LEVEL] $MESSAGE" | tee -a "$LOG_FILE"
fi
}
log_info() { log_message "INFO" "$1"; }
log_warn() { log_message "WARN" "$1"; }
log_error() {
log_message "ERROR" "$1"
exit 1 # 以错误状态码退出脚本
}
# --- 清理函数 (通过 trap 调用) ---
cleanup() {
local exit_status=$? # 在 log_info 改变 $? 之前捕获状态
if [ "$exit_status" -ne 0 ]; then
log_error "脚本异常终止。退出状态码: $exit_status" # 这也会调用 exit 确保一致性
else
log_info "脚本执行成功并结束。"
fi
}
trap cleanup EXIT
# --- 主体任务逻辑 ---
log_info "开始执行系统管理任务。"
# 1. 备份 Web 数据
log_info "尝试将 '$BACKUP_SOURCE' 备份到 '$BACKUP_DEST'..."
# 在尝试 rsync 之前,确保备份目标驱动器已挂载且可写
if ! mountpoint -q "$(dirname "$BACKUP_DEST")"; then
log_error "备份目标目录 '$(dirname "$BACKUP_DEST")' 尚未挂载或不可访问。无法继续备份。"
fi
# 模拟一次成功的 rsync 以供演示
# 在真实场景中,如果 `set -e` 不足以覆盖特殊需求,请显式检查 rsync 的退出状态码
# 现在,我们假设 rsync 失败会自动触发 set -e
mkdir -p "$BACKUP_DEST" # 仅为演示创建目录
cp -r "$BACKUP_SOURCE" "$BACKUP_DEST/" # 仅为演示使用 cp
# rsync -avz --delete "$BACKUP_SOURCE/" "$BACKUP_DEST/"
log_info "已成功备份到 '$BACKUP_DEST'。"
# 2. 更新系统软件包
log_info "正在更新系统软件包..."
# sudo apt update 确保本地包列表是最新的
# sudo apt upgrade -y 在不提示确认的情况下升级软件包
# 如果 apt 失败,`set -e` 会导致脚本直接退出
sudo apt update
sudo apt upgrade -y # 使用 -y 自动回答 yes
log_info "系统软件包已更新。"
# 3. 清理临时文件
log_info "正在清理 /tmp/ 中的临时文件..."
# `find /tmp -mindepth 1 -delete` 通常比 `rm -rf /tmp/*` 更安全
# 因为它避免了 /tmp 为空或包含隐藏/特殊文件时的问题。
# `set -e` 会自动处理 find/delete 潜在的失败。
sudo find /tmp -mindepth 1 -delete
log_info "临时文件清理完毕。"
# 脚本顺利到底,EXIT 陷阱将自动触发并记录成功的结束信息。在这个增强版脚本中:
set -euo pipefail确立了极其严格的防错标准。log_info,log_warn, 和log_error函数提供了完善的控制台+文件双轨结构化日志。log_error直接接管了脚本的异常退出。cleanup函数通过trap EXIT注册,确保了无论成败,都能在日志中留下最终裁决的记录。- 在执行备份等高危操作前,添加了特定的安全检查(如
mountpoint -q),以便尽早发现并拦截潜在故障。 - 合理运用了
sudo来提升执行系统任务的权限。 - 使用了更安全的
find -delete替代了暴力的rm命令来清理临时文件。