Most Bash scripts fail silently. A command fails, but the script keeps running, corrupting data or creating inconsistent states. Professional scripts handle errors gracefully: they stop on failures, clean up temporary files, and tell you what went wrong. In this lesson, you'll learn the error handling and debugging techniques that separate fragile scripts from production-ready automation.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Use
set -eto exit on errors - Prevent undefined variable bugs with
set -u - Debug scripts with
set -xandPS4 - Implement
trapfor cleanup operations - Use
shellcheckto find bugs automatically - Create proper logging for scripts
2. Why This Matters
Real-world scenario: Your deployment script runs 50 commands. Command #23 fails, but the script continues. It tries to deploy with incomplete files, corrupts the server, and you spend 2 hours recovering. With proper error handling, the script would have stopped immediately, logged the error, and alerted you.
3. Core Concepts
The Set Command (Safety Options)
#!/bin/bash
# Standard safety options for production scripts
set -e # Exit immediately if any command fails
set -u # Treat unset variables as errors
set -o pipefail # Pipeline fails if any command in pipe fails
# Or combine them:
set -euo pipefail
# Demonstration
set -e # Comment this out to see the difference
echo "This command will succeed"
ls /tmp
echo "This command will fail"
ls /nonexistent_directory
echo "This line will NOT run if set -e is active"
$ ./script_with_set_e.sh
This command will succeed
This command will fail
ls: cannot access '/nonexistent_directory': No such file or directory
# Script stops here - last line never runs
$ ./script_without_set_e.sh
This command will succeed
This command will fail
ls: cannot access '/nonexistent_directory': No such file or directory
This line WILL run - this is dangerous!
#!/bin/bash
# set -u prevents undefined variables
set -u
name="Alex"
echo "Name: $name"
echo "Age: $age" # This will cause an error - age not defined!
# To allow undefined variables temporarily:
set +u # Disable
maybe_undefined_var="${var:-default}" # Safer way with default
set -u # Re-enable
#!/bin/bash
# set -o pipefail - pipeline fails if any command fails
set -o pipefail
# Without pipefail: only the last command's exit code matters
# With pipefail: if any command fails, the whole pipeline fails
echo "Testing pipefail"
# This will fail (grep fails)
ls /tmp | grep nonexistent | wc -l
echo "Exit code: $?"
# Without pipefail, this would succeed because wc -l succeeds!
# With pipefail, it fails because grep failed
Debugging with set -x
#!/bin/bash
# set -x - print each command before executing (trace mode)
set -x
name="Alex"
echo "Hello, $name"
ls /tmp
# Turn off tracing temporarily
set +x
echo "This won't be traced"
set -x
# Customize trace output (PS4)
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
echo "With line numbers in trace"
# Run script with tracing without modifying file:
# bash -x script.sh
$ bash -x script.sh
+ name=Alex
+ echo 'Hello, Alex'
Hello, Alex
+ ls /tmp
file1.txt file2.txt
+ echo 'This won\'t be traced'
This won't be traced
+ PS4='+ ${BASH_SOURCE}:${LINENO}: '
+ echo 'With line numbers in trace'
+ script.sh:10: echo 'With line numbers in trace'
With line numbers in trace
Trap: Clean Up on Exit
#!/bin/bash
# trap - execute commands on signals or script exit
# Create a temporary file
TEMP_FILE=$(mktemp)
# Cleanup function
cleanup() {
echo "Cleaning up..."
rm -f "$TEMP_FILE"
echo "Temporary file removed"
}
# Trap EXIT (script ends, success or failure)
trap cleanup EXIT
# Trap specific signals
trap 'echo "Script interrupted"; exit 1' INT TERM
# Main script
echo "Creating temp file: $TEMP_FILE"
date > "$TEMP_FILE"
echo "Contents of temp file:"
cat "$TEMP_FILE"
# Simulate work
echo "Working..."
sleep 2
# Cleanup runs automatically when script exits
#!/bin/bash
# Advanced trap usage for lock files
LOCK_FILE="/tmp/myscript.lock"
# Check if script is already running
if [[ -f "$LOCK_FILE" ]]; then
echo "Script already running (PID: $(cat $LOCK_FILE))"
exit 1
fi
# Cleanup function
cleanup() {
echo "Cleaning up lock file..."
rm -f "$LOCK_FILE"
}
# Set traps
trap cleanup EXIT INT TERM
# Create lock file with our PID
echo $$ > "$LOCK_FILE"
echo "Lock file created with PID: $$"
# Script work here
echo "Running script..."
sleep 10
echo "Script finished"
# Lock file will be removed automatically
# Multiple traps in one
trap 'echo "Error on line $LINENO"; cleanup; exit 1' ERR
ShellCheck: Automatic Bug Finder
# Install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck
# macOS
brew install shellcheck
# Run shellcheck
shellcheck myscript.sh
# Common issues shellcheck catches:
# 1. Unquoted variables
# Bad:
rm $file
# Good:
rm "$file"
# 2. Using ls in loops
# Bad:
for file in $(ls *.txt)
# Good:
for file in *.txt
# 3. Comparing numbers with string operators
# Bad:
if [ "$a" == "$b" ] # Works but not for numbers
# Good for numbers:
if [ "$a" -eq "$b" ]
# 4. Missing double quotes
# Bad:
if [ $var = value ]
# Good:
if [ "$var" = "value" ]
Proper Logging
#!/bin/bash
# logging.sh - Professional logging functions
# Log levels
LOG_LEVEL_ERROR=1
LOG_LEVEL_WARNING=2
LOG_LEVEL_INFO=3
LOG_LEVEL_DEBUG=4
# Current log level (set via environment or config)
CURRENT_LOG_LEVEL=${LOG_LEVEL:-3}
LOG_FILE="/var/log/myscript.log"
# Color codes for console
COLOR_RED='\033[0;31m'
COLOR_YELLOW='\033[1;33m'
COLOR_GREEN='\033[0;32m'
COLOR_BLUE='\033[0;34m'
COLOR_NC='\033[0m'
# Logging functions
log_error() {
local msg="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
if [[ $LOG_LEVEL_ERROR -le $CURRENT_LOG_LEVEL ]]; then
echo -e "${COLOR_RED}[ERROR]${COLOR_NC} $timestamp - $msg"
echo "[ERROR] $timestamp - $msg" >> "$LOG_FILE"
fi
}
log_warning() {
local msg="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
if [[ $LOG_LEVEL_WARNING -le $CURRENT_LOG_LEVEL ]]; then
echo -e "${COLOR_YELLOW}[WARNING]${COLOR_NC} $timestamp - $msg"
echo "[WARNING] $timestamp - $msg" >> "$LOG_FILE"
fi
}
log_info() {
local msg="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
if [[ $LOG_LEVEL_INFO -le $CURRENT_LOG_LEVEL ]]; then
echo -e "${COLOR_GREEN}[INFO]${COLOR_NC} $timestamp - $msg"
echo "[INFO] $timestamp - $msg" >> "$LOG_FILE"
fi
}
log_debug() {
local msg="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
if [[ $LOG_LEVEL_DEBUG -le $CURRENT_LOG_LEVEL ]]; then
echo -e "${COLOR_BLUE}[DEBUG]${COLOR_NC} $timestamp - $msg"
echo "[DEBUG] $timestamp - $msg" >> "$LOG_FILE"
fi
}
# Example usage
log_info "Script starting"
log_debug "Debug information"
if [[ ! -f "/etc/config" ]]; then
log_error "Configuration file not found: /etc/config"
exit 1
fi
log_info "Script completed successfully"
4. Complete Project: Robust Backup Script
#!/bin/bash
# robust_backup.sh - Production backup with error handling
# Safety options
set -euo pipefail
# Configuration
BACKUP_SOURCE="${1:-/home/user/data}"
BACKUP_DEST="${2:-/backup}"
LOG_FILE="/var/log/backup.log"
LOCK_FILE="/var/run/backup.lock"
MAX_BACKUPS=10
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Logging function
log() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}
# Trap cleanup
cleanup() {
if [[ -f "$LOCK_FILE" ]]; then
log "INFO" "Removing lock file..."
rm -f "$LOCK_FILE"
fi
log "INFO" "Backup script ended"
}
trap cleanup EXIT INT TERM
# Error handler
error_handler() {
local line=$1
local command=$2
local code=$3
log "ERROR" "Command '$command' failed at line $line with exit code $code"
log "ERROR" "Backup aborted"
exit 1
}
trap 'error_handler ${LINENO} "$BASH_COMMAND" $?' ERR
# Check if script is already running
if [[ -f "$LOCK_FILE" ]]; then
local pid=$(cat "$LOCK_FILE")
if kill -0 "$pid" 2>/dev/null; then
log "ERROR" "Backup already running with PID $pid"
exit 1
else
log "WARNING" "Stale lock file found, removing..."
rm -f "$LOCK_FILE"
fi
fi
# Create lock file
echo $$ > "$LOCK_FILE"
log "INFO" "Backup started with PID $$"
# Validate source
if [[ ! -d "$BACKUP_SOURCE" ]]; then
log "ERROR" "Source directory does not exist: $BACKUP_SOURCE"
exit 1
fi
# Create destination if it doesn't exist
if [[ ! -d "$BACKUP_DEST" ]]; then
log "INFO" "Creating destination directory: $BACKUP_DEST"
mkdir -p "$BACKUP_DEST"
fi
# Create backup filename
BACKUP_NAME="backup_$(basename "$BACKUP_SOURCE")_$(date +%Y%m%d_%H%M%S).tar.gz"
BACKUP_PATH="$BACKUP_DEST/$BACKUP_NAME"
# Perform backup
log "INFO" "Starting backup of $BACKUP_SOURCE"
log "INFO" "Destination: $BACKUP_PATH"
tar -czf "$BACKUP_PATH" -C "$(dirname "$BACKUP_SOURCE")" "$(basename "$BACKUP_SOURCE")"
if [[ $? -eq 0 ]]; then
log "INFO" "Backup completed successfully"
BACKUP_SIZE=$(du -h "$BACKUP_PATH" | cut -f1)
log "INFO" "Backup size: $BACKUP_SIZE"
else
log "ERROR" "Backup failed"
exit 1
fi
# Rotate old backups
log "INFO" "Rotating old backups (keeping $MAX_BACKUPS)"
BACKUP_COUNT=$(ls -1 "$BACKUP_DEST"/backup_*.tar.gz 2>/dev/null | wc -l)
if [[ $BACKUP_COUNT -gt $MAX_BACKUPS ]]; then
TO_DELETE=$((BACKUP_COUNT - MAX_BACKUPS))
log "INFO" "Removing $TO_DELETE old backups"
ls -1t "$BACKUP_DEST"/backup_*.tar.gz | tail -n "$TO_DELETE" | while read -r file; do
log "INFO" "Removing old backup: $(basename "$file")"
rm -f "$file"
done
fi
# Verify backup
log "INFO" "Verifying backup integrity"
if tar -tzf "$BACKUP_PATH" > /dev/null 2>&1; then
log "INFO" "Backup verification successful"
echo -e "${GREEN}✓ Backup completed successfully!${NC}"
echo " File: $BACKUP_PATH"
echo " Size: $BACKUP_SIZE"
else
log "ERROR" "Backup verification failed - backup may be corrupted"
exit 1
fi
log "INFO" "Backup script completed"
exit 0
5. Common Errors & Solutions
6. Summary Checklist
7. Next Steps
Next lesson: Bash Lesson 2.5: Automation with Cron & Systemd Timers
You'll learn to:
- Schedule scripts with
crontab - Use systemd timers
- Monitor scheduled jobs
- Handle job failures
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment