Learning Platform
Глоссарий Troubleshooting
Урок 21.03 · 28 мин
Средний
CapstoneBash codeImplementationfindgzipS3curl

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 скрываем по умолчанию.
log function design
local levellocal + отдельная строка присваивания. Иначе local маскирует exit code subshell (SC2155). Best practice
date -IsecondsISO 8601 с timezone. Стандарт для structured logging. Парсится любым tool
stderr (&>2)Логи в stderr, stdout остаётся для structured data. systemd-journald поймает stderr

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, любыми спецсимволами.

NULL-safe filename handling
find -printNewline-separated output. Сломается на имена с newline в имени файла (легально на Linux)
$(find ...)Word splitting по IFS. Пробелы в именах разорвут на части. Glob expansion на спец. символы
find -print0NULL-terminated (\\0). Single byte, не встречается в filename. Универсально безопасно
mapfile -d ''Reads с NULL delimiter в array. Bash 4.4+

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, есть другие форматы %y time и так далее.
  • 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.

WARNING

Когда source-ишь скрипт в bats тестах — последняя строка main "$@" выполнится! Решение: оберни в if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi. Это позволяет source без выполнения. Стандартный Python-подобный idiom для bash.


Готово: что у нас есть

После этого урока — рабочий cleanup.sh, который:

  1. Соответствует acceptance criteria из урока 01.
  2. Имеет правильную structure (урок 02).
  3. shellcheck clean.
  4. Покрыт bats-тестами.
  5. Запускается с/без —dry-run, на mock environment.

Следующий шаг — деплой через systemd (урок 04).


  • Урок 02 (design) — почему такая структура.
  • Урок 04 (systemd) — следующий: deploy.
  • LAB-04 — практика: пошаговый туториал.
  • Модуль 17 (advanced) — все паттерns в коде.
  • Модуль 12 (archives) — gzip basics.

Попробуй сам

  1. Скопируй cleanup.sh из этого урока в свою dev-машину.

  2. Запусти setup.sh из lab — создаст mock /tmp/mock-airflow-logs.

  3. Сделай --dry-run. Прочитай вывод.

  4. Сделай реальный run без —dry-run. Проверь, что старые .log стали .gz.

  5. Повтори. Idempotent? Должно быть 0 candidate files.

  6. Запусти shellcheck. Все ли warnings disabled осознанно?

  7. Напиши минимум 3 bats теста для функций.

  8. Имитируй sigint: запусти, нажми Ctrl-C во время gzip-цикла. Проверь — trap отработал, mess не оставлен.

  9. Запусти два инстанса параллельно: ./cleanup.sh & ./cleanup.sh &. Один должен exit 2 (lock).


Проверка знанийKnowledge check
В функции find_old_logs используется конструкция `mapfile -t -d '' OLD_LOGS < <(find ... -print0)`. Объясни каждую часть: что делает -t, что -d '', что process substitution `< <(...)`, и почему это безопаснее, чем `OLD_LOGS=( $(find ...) )`.
ОтветAnswer
Полный разбор: 1) **find ... -print0** — NULL-terminated output. Между путями \0 (NULL byte) вместо \n (newline). Это позволяет правильно обрабатывать имена файлов содержащие newlines, пробелы, любые спецсимволы. 2) **mapfile** — bash built-in, читает stdin построчно (или по другому delimiter) в массив. 3) **-t** — trim trailing delimiter. Без него каждый элемент массива содержал бы trailing \0. 4) **-d ''** — delimiter пустая строка означает NULL byte. Соответствует -print0. По умолчанию delimiter — newline. 5) **< <(...)** — **process substitution** + redirect. <(cmd) запускает cmd, создаёт /dev/fd/N (anonymous FIFO), путь к которому подставляется. Внешний < направляет этот FIFO в stdin mapfile. Альтернатива cmd | mapfile ... НЕ работает: pipe создаёт subshell, mapfile апдейтит массив там, parent теряет изменения. Process substitution оставляет mapfile в главном shell. **Почему безопаснее, чем OLD_LOGS=( \$(find ...) )**: 1) Без кавычек \$() подвергается word splitting по IFS. Имена с пробелами разорвутся на части. 2) Newline в имени файла -> bash считает это разделителем элементов. 3) Glob expansion: имя содержащее * вызовет дополнительный glob. 4) IFS=\$'\n\t' помогает, но не от newlines в filenames. **NULL-safe pattern (find -print0 + mapfile -d '')** — единственно правильное решение для arbitrary filenames в production-bash. Это стандарт для DE, где имена файлов могут приходить от пользователей/external systems и быть unpredictable.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 5. Почему в find_old_logs() используется `mapfile -t -d '' < <(find ... -print0)` вместо `arr=( $(find ...) )`?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4