# Задача 08 — bash: разбор лога доступа Написать `solution.sh`, который принимает путь к файлу лога (формат nginx/apache access.log) и печатает ровно четыре строки: ``` TOTAL <число строк в файле> TOP <число запросов> TOP <число запросов> TOP <число запросов> 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`**: ```bash local total=$(wc -l < "$file") # опасно под set -e ``` Здесь исполняются фактически две команды: `wc -l` внутри подстановки `$(...)` и сам `local`. Если `wc -l` завершится с ошибкой, `set -e` эту ошибку не поймает, потому что итоговый код возврата всей строки — это код возврата `local`, а `local` возвращает 0 (успех) сам по себе, даже если команда внутри `$(...)` упала. Подстановка «теряется» внутри составной команды. Лечится разделением на две строки: ```bash 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, который читает и агрегирует файл целиком за один запуск процесса.