Архитектура скрипта: design before coding
Перед тем как писать код — спроектируй. Junior часто сразу садится за editor и пишет линейно: shebang, find, gzip, готово. Через час понимает, что нужен --dry-run, и переписывает половину. Ещё час — добавляет лог, опять переписывает. К концу дня — путаный скрипт, который никто не хочет maintain.
Profession DE начинает с структуры. Какие функции должны быть? Какие переменные глобальные? Где валидация, где работа, где cleanup? 10 минут планирования экономят 2 часа реверсии.
В этом уроке: skeleton production-bash скрипта, дизайн каждой части, ключевые решения (где держать config, как делать idempotent, как структурировать функции).
Skeleton: общая структура production-bash
#!/bin/bash
# ============================================================
# cleanup.sh — Airflow log cleanup and archival
# ============================================================
# 1. PREAMBLE: strict mode
set -euo pipefail
IFS=$'\n\t'
# 2. METADATA: constants (uppercase, readonly)
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly VERSION="1.0.0"
# 3. DEFAULTS: configurable values
LOG_DIR="/var/log/airflow"
RETENTION_DAYS=7
S3_BUCKET=""
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
DRY_RUN=false
VERBOSE=false
LOCKFILE="/var/run/${SCRIPT_NAME%.sh}.lock"
TMPDIR=""
# 4. HELPERS: small reusable functions
usage() { ... }
log() { ... }
slack_notify() { ... }
cleanup() { ... }
maybe_run() { ... }
# 5. CORE: business logic functions
acquire_lock() { ... }
find_old_logs() { ... }
compress_logs() { ... }
upload_to_s3() { ... }
report_stats() { ... }
# 6. CLI: парсинг аргументов
parse_args() { ... }
# 7. MAIN: orchestration
main() {
parse_args "$@"
acquire_lock
log INFO "Starting $SCRIPT_NAME (retention: ${RETENTION_DAYS}d, dry-run: $DRY_RUN)"
# Setup state
TMPDIR=$(mktemp -d -t "${SCRIPT_NAME%.sh}.XXXXXX")
trap cleanup EXIT
# Work
find_old_logs
compress_logs
[ -n "$S3_BUCKET" ] && upload_to_s3
report_stats
log INFO "Completed successfully"
}
# 8. ENTRY POINT
main "$@"
Каждая секция отделена комментариями. Файл читается сверху вниз: что есть -> как использовать -> главное.
Helper functions: log, slack_notify, cleanup, maybe_run
log
Structured logging — критично для дебага в production.
log() {
local level="$1"
shift
local timestamp
timestamp=$(date -Iseconds)
local msg="$timestamp [$level] $SCRIPT_NAME: $*"
# Если verbose или level != DEBUG — печатаем в stderr
if $VERBOSE || [ "$level" != "DEBUG" ]; then
echo "$msg" >&2
fi
# Доп: записать в файл (полезно когда journald недоступен)
# echo "$msg" >> /var/log/cleanup.log
}
# Usage:
log INFO "Starting cleanup"
log WARN "Found 5 corrupted files, skipping"
log ERROR "S3 upload failed: $err"
log DEBUG "Processing file $f"
Принцип: уровни (DEBUG/INFO/WARN/ERROR), timestamp в ISO 8601, имя скрипта. Stderr для логов — stdout остаётся для CSV/structured output.
slack_notify
slack_notify() {
local msg="$1"
if [ -z "$SLACK_WEBHOOK" ]; then
log DEBUG "SLACK_WEBHOOK not set, skipping notification"
return 0
fi
# Best-effort: не валить скрипт если Slack недоступен
curl --max-time 5 --silent \
-X POST \
-H 'Content-Type: application/json' \
--data "$(jq -c -n --arg t "$msg" '{text: $t}')" \
"$SLACK_WEBHOOK" >/dev/null 2>&1 \
|| log WARN "Slack notification failed: $msg"
}
# Usage:
slack_notify "Cleanup succeeded: 245 files compressed, 12.3 GB freed"
slack_notify "Cleanup FAILED on $(hostname): $error_msg"
Ключевые детали:
--max-time 5— не висеть, если Slack недоступен.jq -c -n --arg t ...— безопасный JSON-encoding (экранирует кавычки в msg).|| log WARN— fallback, скрипт продолжает.
cleanup
cleanup() {
local exit_code=$?
# 1. Tmpdir
if [ -n "$TMPDIR" ] && [ -d "$TMPDIR" ]; then
rm -rf "$TMPDIR"
log DEBUG "Removed tmpdir: $TMPDIR"
fi
# 2. Lockfile (через flock автоматически освободится, но удалим)
[ -f "$LOCKFILE" ] && rm -f "$LOCKFILE"
# 3. Notify
if [ $exit_code -eq 0 ]; then
log INFO "Exiting with code 0 (success)"
else
log ERROR "Exiting with code $exit_code (failure)"
slack_notify ":x: $SCRIPT_NAME FAILED on $(hostname) (exit $exit_code)"
fi
exit $exit_code
}
local exit_code=$? обязательно первая строка — иначе перетрётся следующими командами.
maybe_run
maybe_run() {
if $DRY_RUN; then
log INFO "[DRY-RUN] Would run: $*"
else
log DEBUG "Running: $*"
"$@"
fi
}
# Usage:
maybe_run rm "$old_file"
maybe_run aws s3 cp "$f" "s3://$S3_BUCKET/"
maybe_run gzip "$path"
Wrapper отделяет логику от mode-checking. Без maybe_run каждое место с действием обмазалось бы if $DRY_RUN.
Core functions: business logic
acquire_lock
acquire_lock() {
exec 9>"$LOCKFILE" || {
log ERROR "Cannot open lockfile: $LOCKFILE"
exit 1
}
if ! flock -n 9; then
log INFO "Another instance is running (lock $LOCKFILE held), exiting"
exit 2 # exit 2 = lock held (отличается от generic error 1)
fi
log DEBUG "Lock acquired: $LOCKFILE (FD 9)"
}
exec 9>file открывает FD 9 на запись. flock -n 9 пытается захватить exclusive lock (non-blocking). При выходе скрипта FD 9 закроется, lock освободится автоматически.
Exit code 2 — convention: “не error, но не делаем работу”. systemd увидит и не будет ретраить.
find_old_logs
declare -ga OLD_LOGS=() # global array
find_old_logs() {
log INFO "Searching $LOG_DIR for files older than $RETENTION_DAYS days..."
# mapfile -d '' читает null-terminated input (для filename safety)
mapfile -t -d '' OLD_LOGS < <(
find "$LOG_DIR" \
-type f \
-name '*.log' \
-mtime +"$RETENTION_DAYS" \
-not -name '*.gz' \
-print0
)
log INFO "Found ${#OLD_LOGS[@]} files to process"
if [ "${#OLD_LOGS[@]}" -eq 0 ]; then
log INFO "Nothing to do, exiting"
exit 0
fi
}
Ключевые детали:
-not -name '*.gz'— не трогаем уже сжатые (idempotency).-print0+mapfile -d ''— NULL-terminated, безопасно для имён с newlines/spaces.- Глобальный массив
OLD_LOGS— пробрасывается вcompress_logs. - Если пусто — graceful exit 0.
compress_logs
declare -gi COMPRESSED_COUNT=0
declare -gi BYTES_FREED=0
compress_logs() {
log INFO "Compressing ${#OLD_LOGS[@]} files..."
for f in "${OLD_LOGS[@]}"; do
local orig_size
orig_size=$(stat -c %s "$f" 2>/dev/null) || {
log WARN "Cannot stat $f, skipping"
continue
}
if $DRY_RUN; then
log INFO "[DRY-RUN] Would compress: $f ($orig_size bytes)"
COMPRESSED_COUNT=$((COMPRESSED_COUNT + 1))
continue
fi
if gzip -9 "$f"; then
local gz_size
gz_size=$(stat -c %s "${f}.gz")
BYTES_FREED=$((BYTES_FREED + orig_size - gz_size))
COMPRESSED_COUNT=$((COMPRESSED_COUNT + 1))
log DEBUG "Compressed $f: $orig_size -> $gz_size bytes"
else
log WARN "Failed to compress $f"
fi
done
log INFO "Compressed $COMPRESSED_COUNT files, freed $BYTES_FREED bytes"
}
Idempotency: gzip сам заменяет file.log на file.log.gz атомарно. Если файл уже .gz — find его не выберет (-not -name '*.gz'). Можно запускать дважды подряд, второй раз ничего не сделает.
upload_to_s3
upload_to_s3() {
[ -z "$S3_BUCKET" ] && return 0
log INFO "Uploading compressed logs to s3://$S3_BUCKET/"
local date_prefix
date_prefix=$(date +%Y/%m/%d)
local target="s3://$S3_BUCKET/$date_prefix/$(hostname)/"
if $DRY_RUN; then
log INFO "[DRY-RUN] Would sync $LOG_DIR/*.gz -> $target"
return 0
fi
# aws s3 sync — idempotent (skip identical files)
if aws s3 sync "$LOG_DIR/" "$target" \
--exclude '*' \
--include '*.gz' \
--storage-class STANDARD_IA \
--no-progress; then
log INFO "S3 sync succeeded"
# Опционально удалить локальные copies после upload:
# find "$LOG_DIR" -name '*.gz' -mtime +1 -delete
else
log ERROR "S3 sync failed"
return 1
fi
}
aws s3 sync идемпотентен — повторный run не залить заново уже существующие файлы. --storage-class STANDARD_IA — Infrequent Access, дешевле для archive. Для real archive — GLACIER или DEEP_ARCHIVE.
report_stats
report_stats() {
local human_freed
human_freed=$(numfmt --to=iec --suffix=B "$BYTES_FREED" 2>/dev/null || echo "$BYTES_FREED bytes")
local msg=":white_check_mark: ${SCRIPT_NAME} succeeded on $(hostname)
- Processed: ${COMPRESSED_COUNT} files
- Freed: ${human_freed}
- Retention: ${RETENTION_DAYS} days
- Mode: $($DRY_RUN && echo 'DRY-RUN' || echo 'real')"
log INFO "$msg"
slack_notify "$msg"
}
numfmt — coreutils utility для human-readable размеров. 12345678 -> 12M.
CLI: parse_args
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Airflow log cleanup and archival
Usage:
$SCRIPT_NAME [OPTIONS]
Options:
-l, --log-dir DIR Airflow logs directory (default: /var/log/airflow)
-r, --retention-days N Keep logs newer than N days (default: 7)
-s, --s3-bucket BUCKET Optional S3 bucket for archive
-w, --slack-webhook URL Slack webhook URL for notifications (or env SLACK_WEBHOOK)
-d, --dry-run Preview actions without execution
-v, --verbose Enable debug logging
-h, --help Show this help
-V, --version Show version
Examples:
$SCRIPT_NAME --dry-run
$SCRIPT_NAME -r 14 -s acme-airflow-archive
$SCRIPT_NAME -v --log-dir /opt/airflow/logs
Exit codes:
0 Success
1 Generic error
2 Lock held by another instance (no work to do)
Environment:
SLACK_WEBHOOK Default value for --slack-webhook
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-l|--log-dir)
LOG_DIR="$2"; shift 2 ;;
-r|--retention-days)
RETENTION_DAYS="$2"; shift 2 ;;
-s|--s3-bucket)
S3_BUCKET="$2"; shift 2 ;;
-w|--slack-webhook)
SLACK_WEBHOOK="$2"; shift 2 ;;
-d|--dry-run)
DRY_RUN=true; shift ;;
-v|--verbose)
VERBOSE=true; shift ;;
-h|--help)
usage; exit 0 ;;
-V|--version)
echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
# Validation
if [ ! -d "$LOG_DIR" ]; then
echo "Error: log dir does not exist: $LOG_DIR" >&2
exit 1
fi
if ! [[ "$RETENTION_DAYS" =~ ^[0-9]+$ ]]; then
echo "Error: retention-days must be non-negative integer, got: $RETENTION_DAYS" >&2
exit 1
fi
}
Используем case-loop вместо getopts, потому что хотим long options. Поддерживаем -r 7 и --retention-days 7. После цикла — валидация: directory exists, retention is number.
Idempotency: что значит “запустить дважды можно”
Что обеспечивает idempotency в нашем скрипте:
- find -not -name ‘*.gz’ — не подбираем уже сжатые.
- gzip атомарный — либо весь .gz создан и .log удалён, либо ничего.
- aws s3 sync — идемпотентен сам по себе (skips identical files).
- Lock — два запуска одновременно безопасны: второй увидит lock, exit 2.
Logging strategy
Наш паттерн: echo $msg >&2 — stderr. systemd-journald поймает. Опционально — tee -a /var/log/cleanup.log для дублирования в файл.
Errors strategy
# Преамбула set -euo pipefail означает:
# - Любая команда упала -> exit
# - Undefined переменная -> exit
# - Pipeline fail -> exit
# Но иногда нужно явно обрабатывать ошибку:
# 1. Безопасная переменная (опциональная):
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}" # пусто если не задано
# 2. Игнорировать non-critical fail:
slack_notify "$msg" || log WARN "Slack failed but continuing"
# 3. Catch + retry pattern:
retries=3
while (( retries > 0 )); do
if aws s3 cp ...; then break; fi
retries=$((retries - 1))
log WARN "S3 upload failed, $retries retries left"
sleep 5
done
if (( retries == 0 )); then
log ERROR "S3 upload failed after retries"
return 1
fi
# 4. Graceful degradation: critical vs nice-to-have
# Critical: lock acquire, log compression
# Nice-to-have: S3 upload, Slack notification
# Скрипт может succeed без nice-to-have, но fail если critical падает
File layout summary
После урока у тебя должен быть plan:
cleanup.sh:
preamble (set -euo pipefail)
constants (readonly)
defaults (LOG_DIR, RETENTION_DAYS, ...)
helpers:
usage()
log()
slack_notify()
cleanup()
maybe_run()
core:
parse_args()
acquire_lock()
find_old_logs()
compress_logs()
upload_to_s3()
report_stats()
main()
main "$@"
~200 строк production-grade bash. В следующем уроке — пишем код с детальным разбором.
Cross-links
- Урок 01 (overview) — что мы строим и почему.
- Урок 03 (implementation) — следующий: write the code.
- Урок 04 (systemd) — deploy.
- Модуль 17 (advanced bash) — все патterns здесь.
Попробуй сам
-
Возьми лист бумаги. Запиши свой skeleton — без кода, только структуру.
-
Подумай: какие сложности предвидишь? Где можно ошибиться? Запиши в “risks list”.
-
Открой mock environment lab. Сделай первый naive скрипт — просто find + gzip:
#!/bin/bash
set -euo pipefail
find /var/log/airflow -name '*.log' -mtime +7 -type f -print0 \
| xargs -0 gzip -9
Запусти. Что не так с этим naive подходом? Какие production-grade missing pieces?
-
Добавь preamble, trap, log helper. По одному на раз.
-
Прочитай свой код. Удовлетворяет ли он acceptance criteria из урока 01?