Implementation walkthrough: пишем cleanup.sh
В уроке 02 мы спроектировали architecture. Теперь — implementation. Пройдём по cleanup.sh снизу вверх, объясняя почему каждая строка такая, а не другая. К концу урока у тебя будет полный production-grade скрипт, готовый к деплою.
Полный код cleanup.sh
#!/bin/bash
# ============================================================================
# cleanup.sh — Airflow log cleanup and archival
# ============================================================================
# Daily maintenance: find old logs (>N days), gzip compress, optionally
# upload to S3 archive, delete originals, report stats to Slack.
#
# Usage:
# cleanup.sh [--log-dir DIR] [--retention-days N] [--s3-bucket BUCKET]
# [--slack-webhook URL] [--dry-run] [--verbose] [--help]
#
# Exit codes:
# 0 Success
# 1 Generic error
# 2 Lock held by another instance
# ============================================================================
# --- 1. Strict mode ---------------------------------------------------------
set -euo pipefail
IFS=$'\n\t'
# --- 2. Constants -----------------------------------------------------------
readonly SCRIPT_NAME="$(basename "$0")"
readonly VERSION="1.0.0"
# --- 3. Defaults (overridable via CLI / env) -------------------------------
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. State (mutable globals) --------------------------------------------
declare -ga OLD_LOGS=()
declare -gi COMPRESSED_COUNT=0
declare -gi BYTES_FREED=0
START_TIME=$(date -Iseconds)
# --- 5. Helpers -------------------------------------------------------------
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Airflow log cleanup and archival
Usage:
$SCRIPT_NAME [OPTIONS]
Options:
-l, --log-dir DIR Logs directory (default: $LOG_DIR)
-r, --retention-days N Keep logs newer than N days (default: $RETENTION_DAYS)
-s, --s3-bucket BUCKET Optional S3 bucket for archive
-w, --slack-webhook URL Slack webhook URL (or env SLACK_WEBHOOK)
-d, --dry-run Preview only, no changes
-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
EOF
}
log() {
local level="$1"
shift
local timestamp
timestamp=$(date -Iseconds)
if $VERBOSE || [ "$level" != "DEBUG" ]; then
echo "$timestamp [$level] $SCRIPT_NAME: $*" >&2
fi
}
slack_notify() {
local msg="$1"
if [ -z "$SLACK_WEBHOOK" ]; then
log DEBUG "SLACK_WEBHOOK not set, skipping notification"
return 0
fi
local payload
payload=$(jq -c -n --arg t "$msg" '{text: $t}') || {
log WARN "Failed to encode JSON for Slack"
return 0
}
if ! curl --max-time 5 --silent --fail \
-X POST \
-H 'Content-Type: application/json' \
--data "$payload" \
"$SLACK_WEBHOOK" >/dev/null 2>&1; then
log WARN "Slack notification failed (non-fatal)"
fi
}
cleanup() {
local exit_code=$?
if [ -n "$TMPDIR" ] && [ -d "$TMPDIR" ]; then
rm -rf "$TMPDIR"
fi
[ -f "$LOCKFILE" ] && rm -f "$LOCKFILE" 2>/dev/null || true
if [ $exit_code -ne 0 ] && [ $exit_code -ne 2 ]; then
slack_notify ":x: \`$SCRIPT_NAME\` FAILED on \`$(hostname)\` (exit $exit_code)"
log ERROR "Exiting with code $exit_code"
fi
exit $exit_code
}
maybe_run() {
if $DRY_RUN; then
log INFO "[DRY-RUN] Would: $*"
else
log DEBUG "Running: $*"
"$@"
fi
}
# --- 6. Core functions ------------------------------------------------------
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
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: $RETENTION_DAYS" >&2
exit 1
fi
}
acquire_lock() {
exec 9>"$LOCKFILE" || {
echo "Error: cannot open lockfile $LOCKFILE" >&2
exit 1
}
if ! flock -n 9; then
log INFO "Another instance is running, exiting"
exit 2
fi
log DEBUG "Lock acquired: $LOCKFILE"
}
find_old_logs() {
log INFO "Searching $LOG_DIR for files older than ${RETENTION_DAYS} days..."
mapfile -t -d '' OLD_LOGS < <(
find "$LOG_DIR" \
-type f \
-name '*.log' \
-mtime +"$RETENTION_DAYS" \
-not -name '*.gz' \
-print0
)
log INFO "Found ${#OLD_LOGS[@]} candidate files"
}
compress_logs() {
if [ "${#OLD_LOGS[@]}" -eq 0 ]; then
log INFO "No files to compress"
return 0
fi
log INFO "Compressing ${#OLD_LOGS[@]} files..."
for f in "${OLD_LOGS[@]}"; do
local orig_size
if ! orig_size=$(stat -c %s "$f" 2>/dev/null); then
log WARN "Cannot stat $f, skipping"
continue
fi
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" 2>/dev/null; then
local gz_size
gz_size=$(stat -c %s "${f}.gz" 2>/dev/null) || gz_size=0
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"
}
upload_to_s3() {
if [ -z "$S3_BUCKET" ]; then
log DEBUG "S3_BUCKET not set, skipping upload"
return 0
fi
if ! command -v aws >/dev/null 2>&1; then
log ERROR "aws CLI not installed, cannot upload to S3"
return 1
fi
local date_prefix
date_prefix=$(date +%Y/%m/%d)
local target="s3://${S3_BUCKET}/${date_prefix}/$(hostname)/"
log INFO "Syncing compressed logs to $target"
if $DRY_RUN; then
log INFO "[DRY-RUN] Would run: aws s3 sync $LOG_DIR/ $target --include '*.gz'"
return 0
fi
if aws s3 sync "$LOG_DIR/" "$target" \
--exclude '*' \
--include '*.gz' \
--storage-class STANDARD_IA \
--only-show-errors; then
log INFO "S3 sync succeeded"
else
log ERROR "S3 sync failed"
return 1
fi
}
report_stats() {
local human_freed
if command -v numfmt >/dev/null 2>&1; then
human_freed=$(numfmt --to=iec --suffix=B "$BYTES_FREED" 2>/dev/null || echo "${BYTES_FREED}B")
else
human_freed="${BYTES_FREED}B"
fi
local duration=$SECONDS
local mode_label
mode_label=$($DRY_RUN && echo "DRY-RUN" || echo "real")
local msg=":white_check_mark: \`$SCRIPT_NAME\` succeeded on \`$(hostname)\`
- Files: ${COMPRESSED_COUNT}
- Freed: ${human_freed}
- Retention: ${RETENTION_DAYS} days
- Mode: ${mode_label}
- Duration: ${duration}s"
log INFO "Summary: ${COMPRESSED_COUNT} files, ${human_freed} freed, ${duration}s (${mode_label})"
slack_notify "$msg"
}
# --- 7. Main orchestrator ---------------------------------------------------
main() {
parse_args "$@"
log INFO "Starting $SCRIPT_NAME v$VERSION (retention: ${RETENTION_DAYS}d, dry-run: $DRY_RUN)"
acquire_lock
TMPDIR=$(mktemp -d -t "${SCRIPT_NAME%.sh}.XXXXXX")
trap cleanup EXIT INT TERM
find_old_logs
compress_logs
upload_to_s3
report_stats
log INFO "Completed successfully"
}
# --- 8. Entry point ---------------------------------------------------------
main "$@"
200 строк production-grade bash. Теперь — разбор по секциям.
Разбор: что делает каждая часть
Strict mode
set -euo pipefail
IFS=$'\n\t'
Из урока 17.1. Без этого silent failures. С этим — любая ошибка громко падает.
Constants
readonly SCRIPT_NAME="$(basename "$0")"
readonly VERSION="1.0.0"
readonly — переменная не может быть переписана случайно. basename "$0" берёт имя скрипта без пути — стабильно независимо от того, как вызвано (./cleanup.sh, /opt/cleanup.sh, bash cleanup.sh).
Defaults
LOG_DIR="/var/log/airflow"
RETENTION_DAYS=7
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
Значения по умолчанию. ${SLACK_WEBHOOK:-} — если env var задан, использовать; иначе пустая строка. С set -u без :- обращение упадёт.
LOCKFILE="/var/run/${SCRIPT_NAME%.sh}.lock" — ${VAR%.sh} удаляет суффикс .sh. Получится /var/run/cleanup.lock. Bash parameter expansion (PE).
State globals
declare -ga OLD_LOGS=()
declare -gi COMPRESSED_COUNT=0
-g — global (declare без -g внутри функции делает local). -a — array, -i — integer (арифметические операции). Это mutable state, который функции апдейтят.
log helper
log() {
local level="$1"
shift
local timestamp
timestamp=$(date -Iseconds)
if $VERBOSE || [ "$level" != "DEBUG" ]; then
echo "$timestamp [$level] $SCRIPT_NAME: $*" >&2
fi
}
Разбор:
local level="$1"; shift— берём первый аргумент (уровень), убираем из списка. Остальное в$*.local timestamp; timestamp=$(...)— две строки, не одна! Иначеlocalмаскирует exit code (shellcheck SC2155).date -Iseconds— ISO 8601 с timezone:2026-05-13T15:30:00+00:00.>&2— в stderr. systemd-journald поймает.$VERBOSE || [ ... ]— если verbose true, либо если level не DEBUG — пишем. DEBUG скрываем по умолчанию.
slack_notify
Webhooks и Incoming Webhooks Slack — протокол HTTP POST уведомленийslack_notify() {
local msg="$1"
if [ -z "$SLACK_WEBHOOK" ]; then
return 0
fi
local payload
payload=$(jq -c -n --arg t "$msg" '{text: $t}')
if ! curl --max-time 5 --silent --fail \
-X POST \
-H 'Content-Type: application/json' \
--data "$payload" \
"$SLACK_WEBHOOK" >/dev/null 2>&1; then
log WARN "Slack notification failed (non-fatal)"
fi
}
Ключевые моменты:
[ -z "$SLACK_WEBHOOK" ]— если пусто, ранний return.jq -c -n --arg t "$msg" '{text: $t}'— безопасное JSON-кодирование.-n— без stdin,--arg t value— задаёт переменную$tсо значением msg. Использование--argкритично: jq экранирует кавычки и спецсимволы в msg. Без этого если в msg есть"— Slack получит broken JSON.curl --max-time 5— timeout 5 секунд. Slack down? Не висим.--silent --fail— нет progress bar, exit на HTTP error.>/dev/null 2>&1— выкидываем output.if ! curl ...— best-effort, не критично.
cleanup trap handler
cleanup() {
local exit_code=$?
if [ -n "$TMPDIR" ] && [ -d "$TMPDIR" ]; then
rm -rf "$TMPDIR"
fi
[ -f "$LOCKFILE" ] && rm -f "$LOCKFILE" 2>/dev/null || true
if [ $exit_code -ne 0 ] && [ $exit_code -ne 2 ]; then
slack_notify ":x: \`$SCRIPT_NAME\` FAILED on \`$(hostname)\` (exit $exit_code)"
log ERROR "Exiting with code $exit_code"
fi
exit $exit_code
}
local exit_code=$?первая строка — иначе перетрётся.[ -n "$TMPDIR" ] && [ -d "$TMPDIR" ]— два check. Если TMPDIR не установлен (упали до mktemp), не пытаемся удалять.rm -f LOCKFILE 2>/dev/null || true— если уже удалён (или нет прав), не валим cleanup.if exit_code != 0 && != 2— не нотифицируем при exit 2 (lock held — это OK, не error).exit $exit_code— сохраняем оригинальный код, не маскируем последней командой.
find_old_logs
mapfile -t -d '' OLD_LOGS < <(
find "$LOG_DIR" \
-type f \
-name '*.log' \
-mtime +"$RETENTION_DAYS" \
-not -name '*.gz' \
-print0
)
Разбор каждого find-предиката:
-type f— только regular files (не директории, не symlinks).-name '*.log'— basename ends with .log.-mtime +"$RETENTION_DAYS"— modification time более N дней назад.+7= больше 7 дней.-7= менее 7 дней.-not -name '*.gz'— исключение уже сжатых. Critical для idempotency.-print0— NULL-terminated output (\0 между paths instead of \n). Защищает от имён файлов с newlines.
mapfile -t -d '' OLD_LOGS < <(...):
mapfile— bash built-in, читает stdin в массив.-t— strip trailing delimiter у каждого элемента.-d ''— delimiter = NULL byte (соответствует find -print0).< <(cmd)— process substitution, передаёт output cmd как файл в stdin mapfile.
Это NULL-safe pattern. Безопасно для имён с пробелами, newlines, любыми спецсимволами.
compress_logs
for f in "${OLD_LOGS[@]}"; do
local orig_size
if ! orig_size=$(stat -c %s "$f" 2>/dev/null); then
log WARN "Cannot stat $f, skipping"
continue
fi
if gzip -9 "$f" 2>/dev/null; then
# success
else
log WARN "Failed to compress $f"
fi
done
"${OLD_LOGS[@]}"— кавычки +@= preserve elements (урок 17.2).stat -c %s "$f"— size файла в байтах.%s— size, есть другие форматы%ytime и так далее.if ! cmd; then— обрабатываем неуспех.continueпропускает this iteration.gzip -9— максимальная compression. Logs хорошо сжимаются.-9медленнее, но IO — bottleneck, не CPU.- gzip атомарный: либо
$f.gzсоздан и$fудалён, либо ничего.
upload_to_s3
Docker volumes и управление хранилищем — аналогия с S3 архивациейif aws s3 sync "$LOG_DIR/" "$target" \
--exclude '*' \
--include '*.gz' \
--storage-class STANDARD_IA \
--only-show-errors; then
log INFO "S3 sync succeeded"
else
log ERROR "S3 sync failed"
return 1
fi
aws s3 syncидемпотентен — skip identical (same key + ETag).--exclude '*' --include '*.gz'— только .gz файлы (фильтр после exclude).--storage-class STANDARD_IA— Infrequent Access, ~50% дешевле STANDARD, доступ слегка медленнее. Для архива logs — идеально.--only-show-errors— не показывать progress на successful uploads.command -v aws >/dev/null— graceful fail если aws CLI не установлен.
report_stats
local human_freed
if command -v numfmt >/dev/null 2>&1; then
human_freed=$(numfmt --to=iec --suffix=B "$BYTES_FREED" 2>/dev/null || echo "${BYTES_FREED}B")
else
human_freed="${BYTES_FREED}B"
fi
numfmt — coreutils, конвертит “12345678” -> “12M”. --to=iec — power-of-2 (KiB, MiB). --suffix=B — добавить “B”. Fallback на raw bytes если numfmt недоступен.
local duration=$SECONDS — bash special variable, секунды с начала скрипта. Free duration measurement.
Тестирование шаг за шагом
Шаг 1: dry-run на mock environment
# Setup mock (из LAB-04):
bash labs/LAB-04-capstone-airflow-log-cleanup/mock-environment/setup.sh
# Dry-run:
./cleanup.sh --dry-run --log-dir /tmp/mock-airflow-logs
Вывод должен показать список файлов с [DRY-RUN] Would compress: .... Никаких изменений.
Шаг 2: реальный run
./cleanup.sh --log-dir /tmp/mock-airflow-logs --verbose
# Проверь:
ls /tmp/mock-airflow-logs/
# Старые файлы должны быть в .gz
Шаг 3: idempotency
./cleanup.sh --log-dir /tmp/mock-airflow-logs
# Output: "Found 0 candidate files"
# Идемпотентно!
Шаг 4: shellcheck
shellcheck cleanup.sh
# Должен быть clean (no warnings)
Шаг 5: bats tests
Создай test_cleanup.bats:
setup() {
source ./cleanup.sh
TMPDIR_TEST=$(mktemp -d)
}
teardown() {
rm -rf "$TMPDIR_TEST"
}
@test "log() writes to stderr" {
run log INFO "test message"
[ "$status" -eq 0 ]
[[ "$output" =~ "INFO" ]]
[[ "$output" =~ "test message" ]]
}
@test "find_old_logs handles empty dir" {
LOG_DIR="$TMPDIR_TEST"
RETENTION_DAYS=7
find_old_logs
[ "${#OLD_LOGS[@]}" -eq 0 ]
}
@test "find_old_logs excludes .gz" {
LOG_DIR="$TMPDIR_TEST"
RETENTION_DAYS=0
touch "$TMPDIR_TEST/old.log"
touch "$TMPDIR_TEST/already.log.gz"
touch -d '8 days ago' "$TMPDIR_TEST/old.log" "$TMPDIR_TEST/already.log.gz"
find_old_logs
[ "${#OLD_LOGS[@]}" -eq 1 ]
[[ "${OLD_LOGS[0]}" =~ "old.log" ]]
}
Запуск: bats test_cleanup.bats.
Когда source-ишь скрипт в bats тестах — последняя строка main "$@" выполнится! Решение: оберни в if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi. Это позволяет source без выполнения. Стандартный Python-подобный idiom для bash.
Готово: что у нас есть
После этого урока — рабочий cleanup.sh, который:
- Соответствует acceptance criteria из урока 01.
- Имеет правильную structure (урок 02).
- shellcheck clean.
- Покрыт bats-тестами.
- Запускается с/без —dry-run, на mock environment.
Следующий шаг — деплой через systemd (урок 04).
Cross-links
- Урок 02 (design) — почему такая структура.
- Урок 04 (systemd) — следующий: deploy.
- LAB-04 — практика: пошаговый туториал.
- Модуль 17 (advanced) — все паттерns в коде.
- Модуль 12 (archives) — gzip basics.
Попробуй сам
-
Скопируй cleanup.sh из этого урока в свою dev-машину.
-
Запусти setup.sh из lab — создаст mock /tmp/mock-airflow-logs.
-
Сделай
--dry-run. Прочитай вывод. -
Сделай реальный run без —dry-run. Проверь, что старые .log стали .gz.
-
Повтори. Idempotent? Должно быть 0 candidate files.
-
Запусти shellcheck. Все ли warnings disabled осознанно?
-
Напиши минимум 3 bats теста для функций.
-
Имитируй sigint: запусти, нажми Ctrl-C во время gzip-цикла. Проверь — trap отработал, mess не оставлен.
-
Запусти два инстанса параллельно:
./cleanup.sh & ./cleanup.sh &. Один должен exit 2 (lock).