Bash — это не «просто командная строка». Это полноценный скриптовый язык, на котором написаны тысячи скриптов деплоя, мониторинга, бэкапов и автоматизации по всему миру. Проблема в том, что большинство bash-скриптов написаны наспех, без понимания подводных камней, и падают в самый неподходящий момент. Эта статья о том, как писать bash-скрипты, которым можно доверять.
Основы: заголовок, который должен быть в каждом скрипте
#!/usr/bin/env bash
# Описание: что делает скрипт
# Автор: your@email.com
# Версия: 1.0.0
set -euo pipefail
IFS=$'\n\t'
Разберём set -euo pipefail — это не магия, это защита:
set -e— скрипт останавливается при ошибке любой командыset -u— ошибка при обращении к неустановленной переменнойset -o pipefail— ошибка в пайпе не скрывается (без этогоfalse | trueвернёт 0)IFS=$'\n\t'— разделитель полей только перевод строки и таб, не пробел
Без этих строк скрипт будет молча продолжаться после ошибок, что приводит к катастрофическим последствиям на продакшне.
Работа с переменными и параметрами
Правильное использование переменных
# Всегда кавычки вокруг переменных!
name="John Doe"
echo "$name" # правильно
echo $name # сломается если пробел в имени
# Значения по умолчанию
database="${DB_NAME:-myapp_production}"
timeout="${TIMEOUT:-30}"
# Проверка обязательного параметра
: "${API_KEY:?'API_KEY is required but not set'}"
# Массивы
servers=("web01" "web02" "web03")
for server in "${servers[@]}"; do
echo "Processing $server"
done
# Ассоциативные массивы (bash 4+)
declare -A ports
ports[web]=80
ports[mysql]=3306
ports[redis]=6379
for service in "${!ports[@]}"; do
echo "$service: ${ports[$service]}"
done
Обработка аргументов командной строки
#!/usr/bin/env bash
set -euo pipefail
# Продвинутая обработка аргументов с getopts
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] <environment>
Options:
-h, --help Show this help
-v, --verbose Verbose output
-n, --dry-run Dry run, don't make changes
-c, --config FILE Config file path (default: /etc/myapp/config.conf)
Environment:
staging Deploy to staging
production Deploy to production
Examples:
$(basename "$0") -v staging
$(basename "$0") --dry-run production
EOF
exit 1
}
# Разбор длинных опций через getopt
OPTS=$(getopt -o hvnc: --long help,verbose,dry-run,config: -n "$(basename "$0")" -- "$@")
eval set -- "$OPTS"
VERBOSE=false
DRY_RUN=false
CONFIG="/etc/myapp/config.conf"
while true; do
case "$1" in
-h|--help) usage ;;
-v|--verbose) VERBOSE=true; shift ;;
-n|--dry-run) DRY_RUN=true; shift ;;
-c|--config) CONFIG="$2"; shift 2 ;;
--) shift; break ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
ENVIRONMENT="${1:-}"
[[ -z "$ENVIRONMENT" ]] && { echo "Error: environment required"; usage; }
[[ "$ENVIRONMENT" != "staging" && "$ENVIRONMENT" != "production" ]] && {
echo "Error: environment must be 'staging' or 'production'"
exit 1
}
$VERBOSE && echo "Config: $CONFIG"
$VERBOSE && echo "Environment: $ENVIRONMENT"
$DRY_RUN && echo "DRY RUN MODE - no changes will be made"
Логирование — правильный подход
# Цвета для терминала
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Лог-файл
LOG_FILE="/var/log/myapp/deploy-$(date +%Y%m%d-%H%M%S).log"
mkdir -p "$(dirname "$LOG_FILE")"
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
# В файл — без цветов
echo "[$timestamp] [$level] $message" >> "$LOG_FILE"
# В терминал — с цветами
case "$level" in
INFO) echo -e "${GREEN}[INFO]${NC} $message" ;;
WARN) echo -e "${YELLOW}[WARN]${NC} $message" ;;
ERROR) echo -e "${RED}[ERROR]${NC} $message" >&2 ;;
DEBUG) $VERBOSE && echo -e "${BLUE}[DEBUG]${NC} $message" ;;
esac
}
# Использование
log INFO "Starting deployment to $ENVIRONMENT"
log WARN "Skipping health check in dry-run mode"
log ERROR "Failed to connect to database"
log DEBUG "Connection string: $DB_URL"
Обработка ошибок и cleanup
trap — всегда убирай за собой
#!/usr/bin/env bash
set -euo pipefail
# Временные файлы
TEMP_DIR=$(mktemp -d)
LOCK_FILE="/tmp/myapp.lock"
# Функция очистки — выполняется при любом выходе
cleanup() {
local exit_code=$?
log INFO "Cleaning up..."
rm -rf "$TEMP_DIR"
rm -f "$LOCK_FILE"
if [[ $exit_code -ne 0 ]]; then
log ERROR "Script failed with exit code $exit_code"
# Отправляем уведомление
send_alert "Deployment failed on $(hostname)" "$LOG_FILE"
fi
exit $exit_code
}
# Обработка сигналов
error_handler() {
local line_number="$1"
log ERROR "Error on line $line_number"
}
trap cleanup EXIT
trap 'error_handler $LINENO' ERR
trap 'log WARN "Interrupted by user"; exit 130' INT TERM
# Защита от параллельного запуска через lockfile
acquire_lock() {
if ! mkdir "$LOCK_FILE" 2>/dev/null; then
local pid
pid=$(cat "$LOCK_FILE/pid" 2>/dev/null || echo "unknown")
log ERROR "Another instance is running (PID: $pid)"
exit 1
fi
echo $$ > "$LOCK_FILE/pid"
log DEBUG "Lock acquired"
}
acquire_lock
Retry-логика для нестабильных операций
# Универсальная функция retry
retry() {
local max_attempts="$1"
local delay="$2"
local description="$3"
shift 3
local cmd=("$@")
local attempt=1
while true; do
log INFO "[$attempt/$max_attempts] Trying: $description"
if "${cmd[@]}"; then
log INFO "Success: $description"
return 0
fi
if [[ $attempt -ge $max_attempts ]]; then
log ERROR "All $max_attempts attempts failed: $description"
return 1
fi
log WARN "Attempt $attempt failed, retrying in ${delay}s..."
sleep "$delay"
((attempt++))
# Экспоненциальная задержка (удваиваем каждый раз, макс 60 сек)
delay=$(( delay * 2 > 60 ? 60 : delay * 2 ))
done
}
# Использование
retry 5 2 "Health check" curl -sf http://localhost/health
retry 3 5 "Database backup" pg_dump myapp > /backup/dump.sql
Параллельное выполнение задач
# Простой способ — запуск в background + wait
deploy_to_servers() {
local servers=("$@")
local pids=()
local failed=0
for server in "${servers[@]}"; do
log INFO "Starting deploy on $server"
(
ssh "$server" 'cd /app && git pull && systemctl restart myapp'
log INFO "Deploy done on $server"
) &
pids+=($!)
done
# Ждём все процессы и собираем статусы
for i in "${!pids[@]}"; do
if ! wait "${pids[$i]}"; then
log ERROR "Deploy failed on ${servers[$i]}"
((failed++))
fi
done
return $failed
}
# С ограничением параллелизма (не более N одновременно)
run_parallel() {
local max_jobs="$1"
shift
local items=("$@")
local running=0
local pids=()
for item in "${items[@]}"; do
# Ждём если достигнут лимит
while [[ $running -ge $max_jobs ]]; do
# Проверяем завершившиеся процессы
local new_pids=()
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
new_pids+=("$pid")
else
((running--))
fi
done
pids=("${new_pids[@]}")
sleep 0.1
done
process_item "$item" &
pids+=($!)
((running++))
done
wait
}
Работа с файлами и текстом
Безопасное чтение конфигурации
# Загрузка .env файла
load_env() {
local env_file="${1:-.env}"
[[ -f "$env_file" ]] || { log WARN "No $env_file found"; return 0; }
# Безопасная загрузка: только KEY=VALUE строки, без выполнения кода
while IFS='=' read -r key value; do
# Пропускаем комментарии и пустые строки
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "$key" ]] && continue
# Удаляем пробелы вокруг ключа
key="${key//[[:space:]]/}"
# Удаляем кавычки из значения
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
# Экспортируем только валидные имена переменных
if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
export "$key=$value"
fi
done < "$env_file"
}
Манипуляции со строками
# Встроенные операции (без fork, быстрее)
string="Hello, World!"
# Длина
echo "${#string}" # 13
# Подстрока (offset:length)
echo "${string:7:5}" # World
# Замена (первое вхождение)
echo "${string/Hello/Hi}" # Hi, World!
# Замена (все вхождения)
path="/usr/local/bin:/usr/bin:/bin"
echo "${path//:/ }" # /usr/local/bin /usr/bin /bin
# Upper/Lower case (bash 4+)
echo "${string^^}" # HELLO, WORLD!
echo "${string,,}" # hello, world!
# Trim (удаление пробелов)
trim() {
local str="$*"
str="${str#"${str%%[![:space:]]*}"}"
str="${str%"${str##*[![:space:]]}"}"
echo "$str"
}
# Разбивка строки на массив
IFS=',' read -ra parts <<< "one,two,three"
for part in "${parts[@]}"; do echo "$part"; done
Реальные примеры: скрипты для продакшна
Скрипт деплоя с zero-downtime
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_DIR="/var/www/myapp"
RELEASES_DIR="$DEPLOY_DIR/releases"
CURRENT_LINK="$DEPLOY_DIR/current"
SHARED_DIR="$DEPLOY_DIR/shared"
KEEP_RELEASES=5
deploy() {
local release_dir
release_dir="$RELEASES_DIR/$(date +%Y%m%d%H%M%S)"
log INFO "Creating release directory: $release_dir"
mkdir -p "$release_dir"
# Клонируем или обновляем код
log INFO "Deploying code..."
if [[ -d "$DEPLOY_DIR/repo" ]]; then
git -C "$DEPLOY_DIR/repo" fetch origin
git -C "$DEPLOY_DIR/repo" archive HEAD | tar -x -C "$release_dir"
else
git clone --depth 1 "$GIT_REPO" "$DEPLOY_DIR/repo"
git -C "$DEPLOY_DIR/repo" archive HEAD | tar -x -C "$release_dir"
fi
# Линкуем shared директории
ln -sfn "$SHARED_DIR/uploads" "$release_dir/public/uploads"
ln -sfn "$SHARED_DIR/.env" "$release_dir/.env"
# Устанавливаем зависимости
log INFO "Installing dependencies..."
composer install --no-dev --optimize-autoloader -d "$release_dir"
# Прогреваем кэш
log INFO "Warming up cache..."
php "$release_dir/spark" cache:clear
# Переключаем симлинк атомарно
log INFO "Switching to new release..."
ln -sfn "$release_dir" "${CURRENT_LINK}.new"
mv -Tf "${CURRENT_LINK}.new" "$CURRENT_LINK"
# Перезагружаем PHP-FPM (graceful)
kill -USR2 $(cat /var/run/php/php8.2-fpm.pid)
# Очищаем старые релизы
cleanup_old_releases
log INFO "Deploy completed successfully!"
}
cleanup_old_releases() {
local releases
releases=$(ls -1t "$RELEASES_DIR")
local count
count=$(echo "$releases" | wc -l)
if [[ $count -gt $KEEP_RELEASES ]]; then
echo "$releases" | tail -n +"$((KEEP_RELEASES + 1))" | while read -r release; do
log INFO "Removing old release: $release"
rm -rf "$RELEASES_DIR/$release"
done
fi
}
rollback() {
local previous
previous=$(ls -1t "$RELEASES_DIR" | sed -n '2p')
if [[ -z "$previous" ]]; then
log ERROR "No previous release to rollback to"
exit 1
fi
log INFO "Rolling back to: $previous"
ln -sfn "$RELEASES_DIR/$previous" "${CURRENT_LINK}.rollback"
mv -Tf "${CURRENT_LINK}.rollback" "$CURRENT_LINK"
kill -USR2 $(cat /var/run/php/php8.2-fpm.pid)
log INFO "Rollback completed"
}
case "${1:-deploy}" in
deploy) deploy ;;
rollback) rollback ;;
*) echo "Usage: $0 [deploy|rollback]"; exit 1 ;;
esac
Скрипт мониторинга и уведомлений
#!/usr/bin/env bash
set -euo pipefail
# Пороговые значения
CPU_THRESHOLD=85
MEM_THRESHOLD=90
DISK_THRESHOLD=85
LOAD_THRESHOLD=4.0
ALERT_EMAIL="ops@example.com"
WEBHOOK_URL="${SLACK_WEBHOOK:-}"
send_alert() {
local subject="$1"
local body="$2"
# Email
echo "$body" | mail -s "[$HOSTNAME] ALERT: $subject" "$ALERT_EMAIL"
# Slack webhook
if [[ -n "$WEBHOOK_URL" ]]; then
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{\"text\":\":warning: *[$HOSTNAME]* $subject\n\`\`\`$body\`\`\`\"}"
fi
}
check_cpu() {
local cpu_usage
cpu_usage=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1 | cut -d',' -f1)
cpu_usage=${cpu_usage%.*} # целая часть
if [[ $cpu_usage -gt $CPU_THRESHOLD ]]; then
local top_processes
top_processes=$(ps aux --sort=-%cpu | head -6 | tail -5)
send_alert "High CPU: ${cpu_usage}%" "CPU usage: ${cpu_usage}%\n\nTop processes:\n$top_processes"
fi
}
check_memory() {
local mem_total mem_used mem_percent
mem_total=$(free -m | awk '/^Mem:/{print $2}')
mem_used=$(free -m | awk '/^Mem:/{print $3}')
mem_percent=$(( mem_used * 100 / mem_total ))
if [[ $mem_percent -gt $MEM_THRESHOLD ]]; then
local top_processes
top_processes=$(ps aux --sort=-%mem | head -6 | tail -5)
send_alert "High Memory: ${mem_percent}%" "Memory: ${mem_used}MB / ${mem_total}MB (${mem_percent}%)\n\n$top_processes"
fi
}
check_disk() {
while IFS= read -r line; do
local usage mount
usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
mount=$(echo "$line" | awk '{print $6}')
if [[ $usage -gt $DISK_THRESHOLD ]]; then
send_alert "High Disk: $mount at ${usage}%" \
"Disk usage on $mount: ${usage}%\n\n$(df -h "$mount")"
fi
done < <(df -h | grep -E '^/dev/' | grep -v tmpfs)
}
check_services() {
local services=("nginx" "php8.2-fpm" "mysql" "redis")
for service in "${services[@]}"; do
if ! systemctl is-active --quiet "$service"; then
send_alert "Service DOWN: $service" \
"Service $service is not running!\n\n$(systemctl status "$service" --no-pager | tail -20)"
# Попытка перезапуска
log WARN "Attempting to restart $service..."
systemctl restart "$service" && \
send_alert "Service RECOVERED: $service" "Service $service was restarted successfully"
fi
done
}
# Запускаем все проверки
check_cpu
check_memory
check_disk
check_services
Лучшие практики
Используйте shellcheck — это ESLint для bash:
apt install shellcheck
shellcheck myscript.sh
# В CI/CD:
find . -name "*.sh" -exec shellcheck {} +
Тестируйте скрипты с bats:
apt install bats
# test.bats:
@test "cleanup removes old files" {
touch /tmp/testfile
run cleanup /tmp/testfile
[ "$status" -eq 0 ]
[ ! -f /tmp/testfile ]
}
bats test.bats
Документируйте через heredoc:
show_help() {
cat <<'EOF'
...документация...
EOF
}
Bash — это мощный инструмент, но он требует дисциплины. Скрипт, написанный правильно — это надёжный автоматизированный сотрудник. Написанный небрежно — бомба с часовым механизмом.
Create an account or sign in to leave a review
There are no reviews to display.