Files
eduplan-cpp-eltex/diag/tasks/08_bash/task.md
T

13 KiB
Raw Blame History

Задача 08 — bash: разбор лога доступа

Написать solution.sh, который принимает путь к файлу лога (формат nginx/apache access.log) и печатает ровно четыре строки:

TOTAL <число строк в файле>
TOP <ip> <число запросов>
TOP <ip> <число запросов>
TOP <ip> <число запросов>
5XX <число ответов со статусом 500-599>

Правила формата

  • IP — первое поле строки;
  • TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию строки IP (лексикографически);
  • если уникальных IP меньше трёх — печатать столько строк TOP, сколько есть;
  • файл может быть большим (миллион строк): построчный bash-цикл по строкам не пройдёт по времени — нужен awk/sort или один-два прохода;
  • пустой файл: TOTAL 0, 5XX 0, строк TOP нет.

Факты для карточек

  • base | Сколько строк TOP выводится, если уникальных IP меньше трёх? — столько, сколько есть уникальных IP
  • base | Что печатает скрипт для пустого файла? — TOTAL 0, 5XX 0, без строк TOP
  • core | По какому полю строки лога определяется IP? — по первому полю

Почему дальше: раз файл может быть на миллион строк, нужно понять, почему построчный while read в bash не укладывается по времени и что вместо этого реально делает работу за O(n) без интерпретации каждой строки внутри самого bash.

Механизм: почему не построчный цикл, а awk/sort

Построчный while read line; do ... done < file на миллионе строк запускает bash-интерпретатор заново на каждую итерацию цикла (разбор строки, вызов внешних утилит вроде cut/grep из цикла добавляет ещё и fork+exec процесса на каждую строку) — при миллионе строк это миллион запусков процессов, что на порядки медленнее одного прохода специализированной утилитой. awk и sort — скомпилированные бинарники, которые сами читают файл целиком за один проход (awk) без порождения процесса на строку, и делают всю агрегацию (подсчёт по IP, подсчёт 5xx, TOTAL) внутри одного вызова.

Практическая схема: один проход awk по файлу одновременно считает TOTAL (число строк), инкрементирует счётчик по IP в ассоциативном массиве и считает строки с кодом 500–599 (код ответа — обычно отдельное поле в access.log), а на выходе печатает пары IP count. Дальше sort сортирует эти пары по счётчику по убыванию, а при равенстве — по IP по возрастанию (составной ключ сортировки), и head -3 берёт первые три строки. Итог — два прохода по данным (awk-агрегация, потом sort уже по маленькому списку уникальных IP, а не по миллиону исходных строк), а не миллион запусков процессов.

Факты для карточек

  • core | Почему while read line в bash медленный на миллионе строк? — каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и fork+exec на строку
  • core | Что даёт связка awk + sort вместо построчного цикла? — один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов
  • deep | По какому полю сортируется список IP при равенстве числа запросов? — по возрастанию строки IP (вторичный ключ сортировки)

Почему дальше: раз скрипт нетривиальный (несколько шагов, внешние утилиты, граничный случай с пустым файлом), в нём легко замаскировать ошибку — отсюда set -e и его реальные границы.

set -e и где он реально ломается

set -e (errexit) останавливает скрипт при первой команде, вернувшей ненулевой код возврата, вместо того чтобы по умолчанию молча идти дальше — это отклонение от исторического поведения bash, оптимизированного под интерактивную сессию, где одна неудачная команда не должна обрывать сессию целиком. set -o pipefail отдельно нужен для конвейеров (awk ... | sort | head): без него код возврата конвейера — это код возврата только последней команды (head), и падение awk или sort посередине останется незамеченным, даже если set -e включён — сам по себе -e конвейер как единое целое не покрывает.

set -e не срабатывает, если неудачная команда стоит частью условия — в if, while, until, слева или справа от &&/||, либо после ! — потому что в этих позициях код возврата команды явно проверяется вызывающим кодом, а не игнорируется, и bash считает это ожидаемым путём выполнения, а не аварией.

Отдельная и менее очевидная ловушка — команда в присваивании переменной внутри local:

local total=$(wc -l < "$file")   # опасно под set -e

Здесь исполняются фактически две команды: wc -l внутри подстановки $(...) и сам local. Если wc -l завершится с ошибкой, set -e эту ошибку не поймает, потому что итоговый код возврата всей строки — это код возврата local, а local возвращает 0 (успех) сам по себе, даже если команда внутри $(...) упала. Подстановка «теряется» внутри составной команды. Лечится разделением на две строки:

total=$(wc -l < "$file")   # код возврата — это код возврата wc -l, set -e сработает
local total

Ловушки

  • Полагаться на голый set -e для пайплайна awk | sort | head → падение awk посередине незаметно, head вернёт 0 → нужен set -o pipefail, видно только по неверному выводу, не по коду возврата.
  • local var=$(cmd) в одну строку → ошибка cmd маскируется кодом возврата local (всегда 0 для валидного объявления) → set -e не остановит скрипт, видно по неверному/пустому var без явной ошибки.
  • Не обработать пустой файл отдельно → sort/head на пустом входе не печатают строк TOP, но если код по ошибке считает TOTAL через конвейер с wc -l | ..., легко перепутать 0 строк с ошибкой чтения файла.
  • Не заквотить "$file" → путь с пробелом разбивается на несколько слов → скрипт падает на несуществующем файле или обрабатывает не тот аргумент.

Факты для карточек

  • base | Что делает set -e? — останавливает скрипт при первой команде с ненулевым кодом возврата
  • core | В каких позициях set -e не останавливает скрипт при ошибке команды? — в условиях if/while/until, слева/справа от &&/||, после !
  • core | Почему local var=$(cmd) не ловится set -e, если cmd упал? — итоговый код возврата строки — это код возврата local, а он 0 даже при упавшей подстановке внутри
  • core | Зачем set -o pipefail отдельно от set -e? — без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным

Проверка

Запуск: bash solution.sh access.log Проверка: python3 grade.py 08 (тест гоняет скрипт на фикстуре и на большом логе). Критерий: точное совпадение вывода, код возврата 0.

Факты для карточек

  • base | Каким кодом возврата должен завершаться solution.sh при успехе? — 0
  • core | Что именно сверяет grade.py 08 с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе
Проверь себя
  1. Почему пайплайн grep 500 access.log | wc -l под одним set -e (без pipefail) может молча пропустить ошибку grep (например, если файл не существует)?

    ОтветКод возврата конвейера по умолчанию — это код возврата последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую упавшую команду.
  2. Почему local total=$(false) не остановит скрипт при set -e, а total=$(false) (без local на той же строке) — остановит?

    ОтветВ первом случае итоговый код возврата строки — это код возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе объявления независимо от результата подстановки внутри; во втором случае код возврата присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e` сработает.
  3. Почему построчный while read ip status rest; do ...; done < access.log с миллионом строк не проходит по времени, даже если внутри цикла нет вызовов внешних утилит?

    ОтветКаждая итерация цикла — это работа самого интерпретатора bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл целиком за один запуск процесса.