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