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

146 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Задача 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>