Learning Platform
Глоссарий Troubleshooting
Урок 21.02 · 22 мин
Средний
CapstoneBash designProduction patternsArchitecture

Архитектура скрипта: 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 "$@"
Anatomy production-bash скрипта
preambleset -euo pipefail + IFS — обязательная защита. Урок 17.1
metadataSCRIPT_NAME, VERSION — readonly constants. Удобно для logging и self-reference
defaultsConfigurable values с дефолтами. Перетираются через getopts. Использовать `${:-}` для опциональных
helperslog, slack_notify, cleanup, maybe_run — переиспользуются. Mаленькие, не более 20 строк каждая
coreBusiness logic — каждая функция делает одну вещь. acquire_lock, find_old_logs, compress_logs, etc
cliparse_args — getopts loop. Изолирован в функцию для testability
mainorchestrator — вызывает helpers и core в правильном порядке. Trap здесь, чтобы быть уверенным что все деклар
entrymain "$@" в самом конце скрипта. Это позволяет тестам source файл без выполнения 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, скрипт продолжает.
Webhooks — как устроены POST-нотификации в Slack

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 атомарно. Если файл уже .gzfind его не выберет (-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 design
run 1Первый запуск: находит 100 файлов, сжимает в .gz, удаляет originals
state AFilesystem state: 100 .gz файлов, 0 .log файлов старше 7 дней
run 2 (immediately)Сразу повторно: find не находит файлов (.gz исключены), graceful exit
state AState не меняется — idempotent. Это критично для retry-логики и cron-overlap

Что обеспечивает idempotency в нашем скрипте:

  1. find -not -name ‘*.gz’ — не подбираем уже сжатые.
  2. gzip атомарный — либо весь .gz создан и .log удалён, либо ничего.
  3. aws s3 sync — идемпотентен сам по себе (skips identical files).
  4. Lock — два запуска одновременно безопасны: второй увидит lock, exit 2.

Logging strategy

Куда писать логи?
stderrСтандарт для logging в скриптах. systemd journald автоматически захватывает stderr из service unit. journalctl -u name -f показывает в realtime
journaldЕсли запущено через systemd — автомат. logs идут в journald, persistent, queryable, по дате/severity
file (optional)/var/log/cleanup.log как fallback. Полезно когда journald недоступен (ручной запуск, debug). Использовать logrotate

Наш паттерн: 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. В следующем уроке — пишем код с детальным разбором.



Попробуй сам

  1. Возьми лист бумаги. Запиши свой skeleton — без кода, только структуру.

  2. Подумай: какие сложности предвидишь? Где можно ошибиться? Запиши в “risks list”.

  3. Открой 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?

  1. Добавь preamble, trap, log helper. По одному на раз.

  2. Прочитай свой код. Удовлетворяет ли он acceptance criteria из урока 01?


Проверка знанийKnowledge check
Опиши, какая структура должна быть у production-grade bash-скрипта, и зачем нужна каждая часть. Назови минимум 7 секций и роль каждой.
ОтветAnswer
Production-bash skeleton (порядок важен — top to bottom): 1) **Shebang** (#!/bin/bash) — точное указание интерпретатора. 2) **Preamble** (set -euo pipefail; IFS=$'\n\t') — strict mode для громкого падения вместо silent failures (модуль 18.1). 3) **Metadata** (readonly SCRIPT_NAME, VERSION) — константы для self-reference и logging. 4) **Defaults** (LOG_DIR=\"/var/log\", DRY_RUN=false) — настраиваемые значения с дефолтами. Использовать \${VAR:-default} для опциональных env. 5) **Helper functions** (usage, log, slack_notify, cleanup, maybe_run) — переиспользуемые маленькие функции. log для structured timestamp+level логов в stderr, maybe_run для --dry-run wrapper, slack_notify best-effort. 6) **Core functions** (acquire_lock, find_old_logs, compress_logs, upload_to_s3, report_stats) — business logic. Каждая функция делает ОДНУ вещь — single responsibility. 7) **CLI parsing** (parse_args через case-loop для long opts или getopts для short) — изолирован в функцию для testability. 8) **main() orchestrator** — вызывает функции в правильном порядке: parse_args -> acquire_lock -> setup tmpdir+trap -> core work -> reporting. 9) **Entry point** (main \"\$@\" в конце) — позволяет source файл в тестах без выполнения main. Зачем такая структура: 1) Читаемость — top-to-bottom повествование; 2) Testability — функции можно тестировать через bats; 3) Maintainability — каждая часть в одном месте; 4) Reusability — helpers переиспользуются; 5) shellcheck friendly — линтер понимает структуру; 6) Debuggability — log helper даёт consistent output. Это reusable template для любого production-bash, не только этого capstone.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. В какой последовательности должны идти секции в production-bash-скрипте?

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

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

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

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