13 KiB
Задача 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с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе
Проверь себя
-
Почему пайплайн
grep 500 access.log | wc -lпод однимset -e(безpipefail) может молча пропустить ошибкуgrep(например, если файл не существует)?Ответ
Код возврата конвейера по умолчанию — это код возврата последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую упавшую команду. -
Почему
local total=$(false)не остановит скрипт приset -e, аtotal=$(false)(безlocalна той же строке) — остановит?Ответ
В первом случае итоговый код возврата строки — это код возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе объявления независимо от результата подстановки внутри; во втором случае код возврата присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e` сработает. -
Почему построчный
while read ip status rest; do ...; done < access.logс миллионом строк не проходит по времени, даже если внутри цикла нет вызовов внешних утилит?Ответ
Каждая итерация цикла — это работа самого интерпретатора bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл целиком за один запуск процесса.