✻ Урок 1.6 · Тема 1: Linux, Bash и systemd
Основы bash и Make: переменные, условия, циклы
Содержание урока
Зачем это нужно
Частая беда: скрипт, который «всё делает», на деле молча возвращает ноль после ошибки. Здесь ты научишься писать так, чтобы он честно говорил, получилось или нет.
Ты уже запускаешь «Заметки» руками: cd ~/notes && python3 app.py, потом в другом окне curl, потом смотришь лог. Пять-шесть команд в правильном порядке помнишь ты, но не коллега и не ты сам через месяц. Поэтому на работе такие последовательности записывают в скрипт (script): текстовый файл, в котором команды лежат в том порядке, в каком их надо выполнять. Скрипт запускается одной командой, одинаково у всех и в три часа ночи.
Второе, чему ты научишься, это make: маленькая программа, которая по файлу Makefile (обычный текстовый файл со списком «имя: команды») выполняет команды проекта под короткими именами (make test, make run). Такое имя называют целью (target): make test значит «выполни цель test». Без этого у каждого проекта свой способ «как это запустить», и новичок его угадывает. Сервис, который никто не умеет проверить одной командой, считается недоделанным. А на собеседованиях любят просить «прочитай этот скрипт и скажи, что он делает и где сломается», поэтому читать чужие скрипты тоже придётся научиться.
Шаг проекта: в ~/notes появляются Makefile с целями run, test, lint (lint проверяет код на ошибки, не запуская его), файл test_app.py (автоматические тесты: маленькая программа, которая сама вызывает сервис и сверяет ответы с ожидаемыми) и пустой requirements.txt (список сторонних библиотек Python, которые нужны проекту; у «Заметок» их нет, поэтому файл пока пуст); make test проверяет сервис за секунды. Код app.py (версия v2.2) не меняется.
Что нужно знать
- Урок 1.1: терминал и файловая система: навигация,
man,--help, что такое оболочка иPATH. - Урок 1.2: текст, потоки и конвейеры: stdout, stderr,
>,2>&1,grep. Без них скрипты бесполезны, потому что скрипт только и делает, что соединяет команды. - Урок 1.3: пользователи, права и sudo:
chmod +x(бит исполнения: отметка в правах файла, которая разрешает запускать его как программу) и владелец файла. - Урок 1.4: процессы и сигналы: что такое процесс, код выхода процесса (число, которое команда возвращает при завершении: 0 значит «успех», остальное «ошибка»), запуск в фоне,
Ctrl+C. - Урок 1.5: диск, память и CPU: в
~/notesлежитapp.pyверсии v2.2 (/leak,/burn). Если файла нет, скачай эталон:curl -fsSL -o ~/notes/app.py https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/versions/v2.2.py.
Картина целиком
Представь повара в ресторане. Есть рецепт: список шагов, которые повар выполняет по порядку. В рецепте есть переменные («соли: столько, сколько указано в заказе»), условия («если яйцо тухлое, выбросить и взять другое») и циклы («мешать, пока не загустеет»). Повар не думает над каждым шагом, он просто исполняет. Переменная (variable) это имя, под которым хранится значение, как подписанная банка на полке: «PORT = 8080». Скрипт это рецепт, а оболочка (shell, программа bash, которую ты уже знаешь из урока 1.1) это повар.
make это пульт с подписанными кнопками на стене кухни: «Открыть смену», «Проверить холодильники». Одна кнопка запускает целую цепочку действий, а подписи кнопок одинаковы для всех поваров.
flowchart LR
U["Ты: ./check.sh"] --> SH["Оболочка bash<br>читает файл сверху вниз<br>подставляет $ПЕРЕМЕННЫЕ<br>выполняет if, for, while"]
SH --> EXT["Внешняя программа, например curl"]
EXT -->|"код выхода 0 или не 0"| SH
SH -.->|"условие читает код выхода"| N["Дальше идёт то, что решил if"]
M["Ты: make test"] --> MK["make ищет цель test в Makefile"]
MK --> CMD["Запускает команды цели:<br>python3 -m unittest -v<br>каждая в новой оболочке"]
Всё урок держится на одной идее: у каждой команды есть код выхода, и вся логика скриптов, make и позднее CI (автоматических проверок кода на сервере, тема 3) строится на этом числе. Дальше по порядку: как оболочка хранит значения (переменные), как она разбирает строку, как принимает решения по коду выхода, как повторяет действия, как проверить сервис через curl и как всё это собрать в Makefile с тестами.
Теория
Скрипт, оболочка и shebang: как запускается файл с командами
Команда в терминале живёт до нажатия Enter. Чтобы повторить её завтра, пришлось бы набирать заново. Скрипт решает это: команды записаны в файл, и файл можно запускать, копировать коллегам, хранить в git и запускать по расписанию.
Скрипт это записка для помощника: «сделай вот это, потом вот это». Оговорка: помощник (оболочка) выполняет записку буквально, без «ты же понимаешь, что я имел в виду». Пропущенный пробел он не прощает.
Скрипт это обычный текстовый файл, например greet.sh (расширение .sh только подсказка людям, системе всё равно). Оболочка читает его сверху вниз и выполняет строку за строкой, как будто ты вводил их с клавиатуры. Символ # начинает комментарий (comment): всё до конца строки игнорируется, это заметки для людей.
Первая строка скрипта особенная: #!/usr/bin/env bash. Она называется shebang (читается «шебанг»: #! и путь). Она отвечает на вопрос «чем запускать этот файл?». Файл с командами сам по себе ничего не умеет, его должна прочитать программа-интерпретатор (interpreter: программа, которая читает текст и по нему выполняет действия). Для .sh это bash, для app.py это python3. Когда ты запускаешь ./greet.sh, ядро (главная часть системы, урок 1.4) читает первую строку, видит #!/usr/bin/env bash и запускает bash greet.sh.
Почему /usr/bin/env bash, а не просто /bin/bash? env находит bash через PATH (список каталогов, где система ищет программы, урок 1.1). На разных системах bash лежит в разных местах, а env всегда в одном: скрипт переносим.
Чтобы файл можно было запускать как программу, у него должен быть бит исполнения (executable bit, урок 1.3): chmod +x greet.sh. Без него будет Permission denied. Есть и второй способ: не давать файлу права, а сказать оболочке прямо: bash greet.sh. Так shebang игнорируется, а права не нужны.
Слова «оболочка» и «bash» не совсем одно и то же. Оболочек несколько: bash (стандарт в Ubuntu), sh (старая упрощённая, в Ubuntu это dash), zsh (на Mac). Их синтаксис похож, но не одинаков. В курсе везде bash, поэтому в shebang пишем именно bash, а не sh.
sequenceDiagram
participant U as Ты
participant B as bash
participant K as ядро
U->>B: ./greet.sh Анна
B->>B: путь с точкой и слэшем: запускать файл, а не искать в PATH
B->>B: бит исполнения есть? нет: Permission denied, код 126
B->>K: запустить файл
K->>K: читает первую строку #!/usr/bin/env bash
K->>B: запускается bash ./greet.sh Анна
Note over B: bash читает файл и выполняет строки
Файл из двух строк:
#!/usr/bin/env bash
echo "Привет"
Запуск ./greet.sh без chmod +x даёт bash: ./greet.sh: Permission denied, а bash greet.sh работает. Если в shebang попал невидимый символ возврата каретки \r (файл сохранён в Windows, где конец строки это два символа), ошибка выглядит так: /usr/bin/env: 'bash\r': No such file or directory. Система ищет программу с именем bash\r, а такой нет.
Прикинь сам: файл
greet.shбезchmod +x. Что даст./greet.sh, а чтоbash greet.sh?
./greet.sh ответит Permission denied (код 126): нет бита исполнения. bash greet.sh сработает, потому что bash сам читает файл как данные.
Осторожно: Думают, что .sh в имени делает файл запускаемым. Нет: запускаемым его делают бит исполнения и shebang. И ещё: ./greet.sh и greet.sh разные команды. Без ./ оболочка ищет программу только в PATH, а текущего каталога там нет, поэтому будет command not found (урок 1.1).
Главное: скрипт это текстовый файл с командами; запускают его битом исполнения и shebang (
#!/usr/bin/env bash), а не расширением.sh.
Проверь понимание: чем
bash greet.shотличается от./greet.sh, и в каком случае shebang вообще не нужен?
Ответ
./greet.sh запускает файл как программу: нужен бит исполнения, а интерпретатора выбирает shebang. bash greet.sh явно запускает bash и передаёт ему файл: бит исполнения не нужен, shebang игнорируется (для bash это просто комментарий). Shebang не нужен, если скрипт всегда запускают через bash имя_файла.
Скрипт запустился. Теперь о том, где он хранит значения: о переменных.
Переменные: где хранятся значения и кто их видит
Одно и то же значение (порт 8080, адрес 127.0.0.1) встречается в скрипте много раз. Если оно записано числом в десяти местах, при смене порта придётся править десять мест и одно забудешь. Переменная позволяет записать значение один раз под именем.
Переменная это коробка с наклейкой. На наклейке имя (PORT), внутри значение (8080). Команда «возьми из коробки PORT» записывается как $PORT. Оговорка: в bash в коробке всегда лежит текст. Число 8080 для bash это строка из четырёх символов, а считать с ним можно только специальным способом (арифметика ниже).
Значение присваивается так: NAME="мир". Между именем, знаком = и значением нет пробелов. Значение достают знаком доллара: $NAME или в фигурных скобках ${NAME}. Скобки нужны, когда имя упирается в другой текст: ${NAME}_backup (без скобок оболочка искала бы переменную NAME_backup). Имена принято писать заглавными для настроек и строчными для временных значений.
Теперь главное, что новички не знают: у переменной есть область видимости. Когда bash запускает другую программу (например python3 app.py), эта программа становится дочерним процессом (child process, процесс, порождённый другим процессом, урок 1.4). Родитель передаёт ребёнку копию окружения (environment): набора переменных, помеченных как передаваемые. Обычная переменная в окружение не попадает, её видит только сам bash.
bash (родитель) python3 app.py (дочерний процесс)
┌───────────────────────┐ запуск ┌──────────────────────────────┐
│ NAME="мир" (только │ ───────► │ окружение: копия только │
│ здесь) │ │ того, что помечено export │
│ export PORT=9000 ────┼────────────┼─► PORT=9000 (виден) │
└───────────────────────┘ └──────────────────────────────┘
Пометить переменную для передачи можно двумя способами. export PORT=9000 действует до конца сеанса оболочки: все последующие программы получат PORT. Запись PORT=9000 python3 app.py (присваивание в той же строке перед командой) действует только на эту одну команду: после неё переменной в оболочке нет. Именно так app.py получает HOST, PORT и NOTES_DATA: он читает их из своего окружения (в коде os.environ). А в systemd-юните (урок 1.8) и в Docker (тема 4) те же переменные задают другими средствами, но принцип тот же.
Проверим границу видимости на настоящих значениях. Команда bash -c 'echo ...' запускает дочерний bash, который выполняет текст из кавычек и выходит:
$ X=1
$ bash -c 'echo "в дочернем: [$X]"'
в дочернем: [] <- X не помечена export, ребёнок её не видит
$ export X
$ bash -c 'echo "в дочернем: [$X]"'
в дочернем: [1] <- теперь видит
$ Y=5 bash -c 'echo "разово: [$Y]"'; echo "после: [$Y]"
разово: [5] <- Y жила только на время команды
после: [] <- в родительской оболочке её нет
Прикинь сам: в терминале выполнено
PORT=9000, затемpython3 app.py. На каком порту запустится сервис?
На 8080: переменная без export осталась только в оболочке, дочерний процесс её не видит. С export PORT=9000 или PORT=9000 python3 app.py порт был бы 9000.
Осторожно: Что export копирует значение «в обе стороны». Нет: ребёнок получает копию, а его изменения родителю не возвращаются. Скрипт не может поменять переменную в твоём терминале, и это правильно.
Главное: переменная живёт в оболочке, а дочерний процесс видит только
export-ные; ребёнок получает копию и родителю ничего не возвращает.
Проверь понимание: в терминале выполнено
PORT=9000, затемpython3 app.py. На каком порту запустится сервис и почему?
Ответ
На 8080 (значение по умолчанию из кода app.py). Переменная PORT без export осталась только в оболочке, а python3 её не получил. Надо было export PORT=9000 или записать в одну строку PORT=9000 python3 app.py.
Переменные есть. Теперь о том, как оболочка разбирает строку перед запуском: о кавычках.
Кавычки, пробелы и звёздочки: как оболочка разбирает строку
Оболочка получает от тебя одну строку текста, а запускаемой программе должна отдать список отдельных слов (аргументов). Кто-то должен решить, где кончается одно слово и начинается другое. Кавычки нужны, чтобы ты мог этим управлять.
Кавычки это упаковка в магазине. Три яблока, лежащие в одном пакете, кассир считает одним товаром, а россыпью уже тремя. Пробел внутри значения это россыпь: без кавычек bash разложит её по отдельным «яблокам». Оговорка: в отличие от магазина, ошибку не заметит никто, пока не станет поздно.
Перед запуском команды bash разбирает строку в фиксированном порядке:
строка: ls -l $FILE (FILE="my notes.txt")
1. подстановка переменных: ls -l my notes.txt
2. разбиение на слова по пробелам: [ls] [-l] [my] [notes.txt] <- 4 слова
3. раскрытие шаблонов (* ? []): слова со звёздочкой заменяются списком файлов
4. запуск: программе ls отдают 3 аргумента: -l, my, notes.txt
строка: ls -l "$FILE"
1. подстановка: ls -l "my notes.txt"
2. разбиение: кавычки защищают [ls] [-l] [my notes.txt] <- 3 слова
4. запуск: 2 аргумента: -l и «my notes.txt»
Три вида кавычек:
- двойные
"...": пробелы защищены, но переменные ($NAME),$(...)и$((...))подставляются; - одинарные
'...': защищено всё, никакие подстановки не работают, текст остаётся как есть; - без кавычек: bash сам режет по пробелам и раскрывает шаблоны.
Шаблоны (glob, «глоб») это символы, которые оболочка заменяет списком подходящих файлов до запуска команды: * это «любая последовательность символов», ? это «один любой символ». *.py превратится в a.py b.py, а my app.py попадёт в список одним элементом (цикл for f in *.py это уважает). Если подходящих файлов нет, шаблон остаётся буквально: for f in *.log при отсутствии логов даст один проход со значением *.log. Про это забывают, и поэтому в скриптах пишут проверку [ -e "$f" ] || continue.
Реальный запуск с файлом my notes.txt:
$ FILE="my notes.txt"
$ ls -l ${FILE}
ls: cannot access 'my': No such file or directory
ls: cannot access 'notes.txt': No such file or directory
$ ls -l "${FILE}"
-rw-r--r-- 1 ubuntu ubuntu 9 Sep 30 11:53 my notes.txt
Первый вызов: ls получил два аргумента, my и notes.txt, и честно сообщил, что таких файлов нет. Второй: аргумент один, и файл найден. Правило для жизни: переменную всегда берёшь в двойные кавычки, кроме случаев, когда разбиение на слова нужно нарочно (это редкость).
Почему это не мелочь? Возьми rm -rf $DIR/* при пустой переменной DIR. Оболочка подставит пустоту и выполнит rm -rf /*: удаление всего, до чего дотянутся права. Кавычки не спасут от пустой переменной, их надо сочетать с защитой ${DIR:?} (разбираем ниже).
Прикинь сам: чем
echo "$HOME"отличается отecho '$HOME'и почемуFILE=my notes.txtне сработает?
Двойные кавычки раскрывают переменную, одинарные печатают текст как есть. В FILE=my notes.txt пробел разрезает строку на два слова: notes.txt оболочка попытается запустить как команду.
Осторожно: Что кавычки нужны «при объявлении цикла или переменной». Нет: кавычки нужны при каждом использовании значения: "${f}". Присваивание FILE=my notes.txt без кавычек тоже не работает, но по другой причине: слово после пробела bash считает уже отдельной командой (notes.txt), а FILE=my действует только на неё.
Главное: оболочка сначала режет строку по пробелам и раскрывает
*, поэтому значения оборачивают в двойные кавычки при каждом использовании:"${f}".
Проверь понимание: чем
echo "$HOME"отличается отecho '$HOME'и почемуFILE=my notes.txtне сработает?
Ответ
В двойных кавычках оболочка подставляет значение (/home/ubuntu), в одинарных выводит текст как есть ($HOME). В FILE=my notes.txt после пробела начинается новая команда notes.txt, а FILE=my действует только на неё. Правильно: FILE="my notes.txt".
Кавычки защищают значение. А как получить значение из команды или арифметики? Для этого подстановки.
Подстановки и параметры: $(...), ${...:-...}, $((...)), $1
Скрипт полезен, когда он подстраивается под обстоятельства: берёт вывод другой команды, подставляет значение по умолчанию, считает, читает то, что ему передали при запуске. Для этого в bash есть несколько «вставок», все начинаются со знака $.
Это бланк с пустыми местами: «Уважаемый __, ваш заказ №__». Перед отправкой бланк заполняют. Оболочка заполняет $-места перед выполнением команды. Оговорка: заполняет она один раз, до запуска, а не «на лету».
Разберём каждую вставку отдельно.
| Запись | Что делает | Пример и результат |
|---|---|---|
$(команда) |
запускает команду, а её stdout подставляет как текст | TODAY="$(date +%F)" даёт 2026-09-30 |
${VAR:-значение} |
значение по умолчанию: если VAR не задана или пуста, берётся значение |
PORT="${PORT:-8080}" |
${VAR:?сообщение} |
если VAR пуста, скрипт останавливается с сообщением |
rm -rf "${DIR:?нужен каталог}"/* |
$((выражение)) |
арифметика (целые числа) | n=$((n + 1)) |
Ещё есть параметры скрипта: то, что ты написал после имени скрипта при запуске (./greet.sh Анна). Слова после имени называются аргументами (arguments). Внутри скрипта они доступны так:
| Запись | Что это |
|---|---|
$0 |
имя самого скрипта (как ты его запустил) |
$1, $2, … |
первый, второй аргумент |
$# |
сколько всего аргументов |
"$@" |
все аргументы, каждый остаётся отдельным словом (то, что нужно в 99% случаев) |
$? |
код выхода последней команды (следующий раздел) |
Скрипт из трёх строк и его запуск:
#!/usr/bin/env bash
echo "имя скрипта: $0"
echo "первый: $1, второй: $2, всего: $#"
for a in "$@"; do echo "[$a]"; done
$ ./args.sh один "два слова"
имя скрипта: ./args.sh
первый: один, второй: два слова, всего: 2
[один]
[два слова]
Кавычки на слове «два слова» при запуске сделали его одним аргументом ($# равен 2, а не 3). "$@" сохранил эту границу: цикл сработал два раза, а не три.
Вложенная запись ${1:-${USER_NAME:-мир}} читается изнутри наружу: «если нет $1, возьми USER_NAME, а если и её нет, возьми слово мир».
Прикинь сам: что напечатает
echo "[${UNSET_VAR}]"иecho "[${UNSET_VAR:-по умолчанию}]", если переменная не задана?
Первая команда [], вторая [по умолчанию]. Форма :- подставляет запасное значение, если переменная пуста или не задана.
Осторожно: $1 и ${1:-x}: в первом случае отсутствующий аргумент даст пустую строку молча, во втором подставится значение по умолчанию. И $(...) с обратными кавычками `...`: старая запись, делает то же, но плохо вкладывается; пиши $(...).
Главное:
$(...)подставляет вывод команды,${x:-значение}запасное значение,$((...))результат арифметики, а$1,$2аргументы скрипта.
Проверь понимание: что напечатает
echo "[${UNSET_VAR}]"иecho "[${UNSET_VAR:-по умолчанию}]", еслиUNSET_VARнигде не задана?
Ответ
[] (пустое место, ошибки нет) и [по умолчанию]. Незаданная переменная в bash молча превращается в пустую строку, поэтому опечатка в имени переменной не вызывает ошибку. Об этом мы ещё вспомним в Makefile (там то же самое) и в уроке 1.7, где режим set -u запрещает такое молчание.
Скрипт научился получать данные. Теперь главное: как он узнаёт, что команда сработала.
Код выхода: как программа говорит «получилось» или «нет»
Скрипт не читает вывод глазами. Ему нужен короткий, надёжный сигнал: сработала команда или нет. Читать человеческий текст («Error: что-то пошло не так») ненадёжно: его меняют, переводят, он бывает в разных форматах. Поэтому у каждой программы есть код выхода (exit code, exit status): число от 0 до 255, которое процесс отдаёт родителю при завершении (в уроке 1.4 ты уже видел код 0 при штатной остановке «Заметок»).
Светофор на выходе: зелёный (0) «всё в порядке», любой другой цвет означает «нет», а какой именно, зависит от договорённости. Оговорка: у светофора зелёный «можно», а для кода 0 это «успех», и логика «0 значит ложь», привычная в программировании, здесь наоборот: 0 это как раз «истина» для условий.
Когда команда завершилась, оболочка записывает её код в специальную переменную $?. Следующая команда перезапишет её, поэтому читать нужно сразу. Соглашение по числам:
| Код | Кто так отвечает | Значение |
|---|---|---|
0 |
все | успех |
1 |
большинство программ | общая ошибка (например, grep не нашёл строку, false) |
2 |
ls, grep, bash |
неверное использование или ошибка (ls нет такого файла) |
7 |
curl |
не удалось соединиться (сервис выключен, порт закрыт) |
22 |
curl -f |
сервер ответил ошибкой 4xx или 5xx |
28 |
curl |
не дождались ответа за отведённое время |
126 |
bash | файл найден, но запустить нельзя (нет бита исполнения) |
127 |
bash | команда не найдена |
130 |
bash | процесс прерван по Ctrl+C |
Свой код скрипт задаёт командой exit N: exit 0 (успех), exit 1 (ошибка). Если exit не вызван, кодом скрипта станет код его последней команды. Договорённость для своих скриптов в этом курсе: 0 успех, 1 проверка не прошла, 2 неверные аргументы.
Зачем это нужно снаружи? Потому что код читает тот, кто запустил скрипт: другой скрипт, make, CI, cron (планировщик запусков по расписанию, урок 1.7). Если скрипт сломался, но вернул 0, все вокруг решат, что всё хорошо.
Реальные коды на стенде:
$ false; echo "false=$?"
false=1
$ true; echo "true=$?"
true=0
$ nosuchcmd; echo "код=$?"
bash: nosuchcmd: command not found
код=127
$ ls /nonexistent; echo "код=$?"
ls: cannot access '/nonexistent': No such file or directory
код=2
true и false это настоящие крошечные программы, которые ничего не делают, а только возвращают 0 и 1: удобны для проверок логики. Обрати внимание, что nosuchcmd завершилась с 127 без всякого exit: код выставила сама оболочка.
Прикинь сам: команда напечатала
Error: disk full, но завершилась кодом 0. Что подумаетmakeили CI?
Что всё хорошо: они смотрят только на код. Поэтому программа обязана возвращать ненулевой код при ошибке.
Осторожно: Что код выхода это «результат» команды. Нет: результат это её вывод (stdout), а код это только «успех или нет». curl может напечатать страницу целиком и вернуть 0, а может ничего не напечатать и вернуть 22. И второе заблуждение: «exit закрывает терминал». В скрипте exit завершает только сам скрипт (в интерактивном терминале он закроет сеанс, поэтому не проверяй его там прямо).
flowchart TD
C["Команда завершилась"] --> Q["Оболочка кладёт код в $?"]
Q --> D{"Код равен 0?"}
D -->|"да"| OK["Успех: if, && и make идут дальше"]
D -->|"нет"| ERR["Ошибка: сработает ||, else, остановка make"]
Q -.->|"следующая команда перезапишет $?"| W["читай сразу или сохрани в переменную"]
Главное: код выхода (
$?) это не результат, а флаг «успех или нет»: 0 успех, остальное ошибка, а 126, 127 и 130 бывают от самого bash.
Проверь понимание: команда напечатала
Error: disk full, но завершилась кодом 0. Что подумаетmakeили CI и почему это плохо?
Ответ
Они смотрят только на код и решат, что всё хорошо, поэтому сборка пойдёт дальше на сломанном диске. Программа обязана возвращать ненулевой код при ошибке, иначе автоматика её не заметит. Если ты пишешь скрипт, завершай его exit 1 после сообщения об ошибке.
Код выхода мы получили. Теперь научим скрипт на него реагировать: условия.
Условия: if, [ ], && и ||
Скрипт должен уметь выбирать: «если сервис жив, скажи OK, иначе ошибка». Условие превращает код выхода в развилку.
Проверка на входе: «есть пропуск, проходи; нет, к охране». Оговорка: bash проверяет не «истинность» выражения, а код выхода команды. Условие в if это не сравнение чисел, а запуск команды.
Конструкция if устроена так:
if команда; then # выполняется команда; если код 0, идёт ветка then
... # то, что делать при успехе
elif другая_команда; then # необязательная: ещё одно условие
...
else # необязательная: всё остальное
...
fi # конец (if наоборот); без него будет syntax error
В роли команды часто выступает [ ... ]. Это не синтаксис, а настоящая команда (программа test с псевдонимом [, в bash встроенная). Поэтому у неё есть код выхода и поэтому вокруг скобок обязательны пробелы: [ -f app.py ], а не [-f app.py] (во втором случае bash ищет команду с именем [-f, и ответ будет command not found, код 127). Последняя ] это просто последний аргумент, указывающий на конец списка.
Что умеет [ ]:
| Проверка | Значение |
|---|---|
-f "$X" |
$X это обычный файл и он существует |
-d "$X" |
$X это каталог |
-e "$X" |
$X существует (файл или каталог) |
-z "$X" |
строка $X пустая |
-n "$X" |
строка $X не пустая |
"$A" = "$B" |
строки равны (один знак =) |
"$N" -gt 5 |
число больше пяти; аналогично -lt меньше, -eq равно, -ge не меньше, -le не больше |
Числа и строки сравниваются разными знаками: = для строк и -eq для чисел. Путать нельзя.
Две вещи до if: && и ||. Запись A && B значит «выполни B, только если A вернула 0». Запись A || B значит «выполни B, только если A вернула не 0». Это короткая запись простых развилок: [ -f app.py ] && echo "app.py на месте". Цепочки читаются слева направо и по очереди.
Реальный запуск проверок:
$ [ -f "my notes.txt" ] && echo "файл есть"
файл есть
$ [-f app.py]; echo "код=$?"
bash: [-f: command not found
код=127
$ [ -f app.py ]; echo "код=$?" # в каталоге нет app.py
код=1
$ E=""; [ $E = x ]; echo "код=$?" # переменная без кавычек и пустая
bash: [: =: unary operator expected
код=2
Последний случай показывает главную ловушку: пустая переменная без кавычек исчезает из строки целиком, и [ $E = x ] превращается в [ = x ]: test видит = там, где ожидал значение. С кавычками [ "$E" = x ] получится [ "" = x ], и ответ будет просто «нет». Если значение содержит пробелы (F="my big file"), сообщение другое: [: too many arguments. Обе ошибки лечатся одним: кавычки вокруг переменной.
Разбор классической загадки: что напечатает false || echo A && echo B?
false -> код 1
|| echo A -> раз предыдущая вернула не 0, выполняем: печатает A, код 0
&& echo B -> предыдущая вернула 0, выполняем: печатает B
Вывод: A и B. Цепочки && и || слева направо равноправны, поэтому сложную логику в них не пиши: для неё есть if.
Прикинь сам: что напечатает
[ -f app.py ] || echo "нет app.py"в каталоге, гдеapp.pyесть? А если нет?
Если файл есть, [ ] вернёт 0, и || не выполнит правую часть: вывода не будет. Если файла нет, напечатается нет app.py.
Осторожно: Что после неудачной проверки в if скрипт «падает». Нет: ветка then просто не выполняется, а скрипт идёт дальше, и его итоговый код может оказаться 0. Пустой else (нет ветки на ошибку) означает «проглотили ошибку». Ещё путают = и ==: в [ ] для строк принят один =, а == работает в bash по недоразумению, но не в sh.
Главное:
ifсмотрит на код выхода команды,&&выполняет следующую команду при успехе,||при неудаче, а[ ]это тоже команда и пробелы в ней обязательны.
Проверь понимание: что напечатает
[ -f app.py ] || echo "нет app.py"в каталоге, гдеapp.pyесть? А если его там нет?
Ответ
Если файл есть, проверка вернёт 0, и || не станет выполнять echo: не напечатается ничего. Если файла нет, код 1, и echo выполнится: нет app.py.
Условия есть. Осталось научить скрипт повторять действие: циклы.
Циклы и арифметика: повторить действие
«Проверь пять серверов», «жди, пока сервис поднимется» и «обработай каждый файл в каталоге» это повторение. Без цикла пришлось бы писать одну и ту же строку пять раз.
Цикл for это «пройтись по списку покупок и взять каждый пункт». Цикл while это «мешать, пока не загустеет»: заранее неизвестно, сколько раз. Оговорка: while может не остановиться никогда, если условие не меняется, поэтому в скриптах ставят потолок числа попыток.
for берёт слова из списка и по очереди кладёт каждое в переменную:
for f in *.py; do # список из шаблона: все файлы .py в каталоге
echo "проверяю ${f}" # тело цикла: выполняется для каждого f
done # конец цикла
for i in 1 2 3; do echo "попытка ${i}"; done # список написан вручную
for i in $(seq 1 5); do echo "${i}"; done # seq 1 5 печатает числа 1 2 3 4 5
Команда seq 1 5 (от sequence, «последовательность») печатает числа от 1 до 5, каждое в своей строке, а $(...) подставляет этот вывод в список. Есть и короткая запись {1..5}, но она не принимает переменные: {1..$N} при N=3 печатает {1..3} буквально (раскрытие фигурных скобок происходит раньше подстановки переменных). Поэтому для переменного числа используй seq 1 "${N}".
while выполняет тело, пока условие возвращает 0. Арифметика делается конструкцией $(( )):
n=0
while [ "${n}" -lt 3 ]; do # пока n меньше трёх
n=$((n + 1)) # увеличиваем n на единицу
done
echo "${n}" # напечатает 3
Внутри цикла есть два полезных слова: break выходит из цикла, continue пропускает остаток текущего прохода.
Цикл по файлам с пробелом в имени (в каталоге a.py, b.py и my app.py):
$ for f in *.py; do echo "[$f]"; done
[a.py]
[b.py]
[my app.py] <- glob отдал имя целиком
$ for f in $(ls *.py); do echo "[$f]"; done
[a.py]
[b.py]
[my] <- $(ls) вернул текст, bash разрезал по пробелам
[app.py]
Первый цикл правильный: раскрытие *.py делает сама оболочка и знает, где кончается имя. Второй ломается: ls печатает имена текстом, а результат $(...) без кавычек режется по пробелам. Отсюда правило «не парси вывод ls».
Прикинь сам: зачем в
for f in *.pyнужны кавычки в"${f}", если в каталоге есть файлmy app.py?
Без кавычек имя разрежется на my и app.py, и команда получит два аргумента. Кавычки нужны при использовании значения, а не вокруг *.py.
Осторожно: Что for f in *.py нуждается в кавычках вокруг *.py. Нет: здесь кавычки, наоборот, отключили бы раскрытие. Кавычки нужны потом, при использовании: ls -l "${f}".
Главное: цикл
forперебирает слова,whileкрутится, пока команда возвращает 0, а арифметика$((i + 1))считает целые числа.
Проверь понимание: зачем в
for f in *.pyнужны кавычки в"${f}", если внутри цикла файлы вродеmy app.py?
Ответ
Цикл сам правильно отдаст my app.py целиком. Но когда ты потом напишешь python3 ${f} без кавычек, оболочка разобьёт значение на my и app.py. Кавычки нужны при каждом использовании переменной, а не в объявлении цикла.
Циклы и условия готовы. Теперь инструмент, который чаще всего в них стоит: curl.
curl как датчик: -f, -s, -S и --max-time
Чтобы скрипт проверил сервис, ему нужен способ спросить «ты жив?» и получить ответ в виде кода выхода. Ты уже знаешь curl (урок 1.1): программа, которая отправляет HTTP-запрос (текст «дай мне страницу по адресу…») и печатает ответ. Осталось научить её сообщать результат кодом.
Позвонить в справочную и услышать «занято» или «ответили». Оговорка: «ответили» ещё не значит «ответили по делу»: оператор мог сказать «такой услуги нет».
Ответ HTTP-сервера состоит из кода состояния (status code: трёхзначное число) и тела. Коды начинаются с цифры: 2xx успех (200 OK), 4xx ошибка запроса (404 Not Found, «такого адреса нет»), 5xx ошибка сервера (500). Подробно это тема урока 2.4. Важно вот что: по умолчанию curl считает свою работу успешной, если сервер вообще ответил, хоть 404, хоть 500, и возвращает 0. Для скрипта это ловушка. Спасают ключи:
-f(--fail): при ответе 4xx и 5xx вернуть код 22 (и не печатать тело);-s(--silent): не показывать индикатор загрузки (он пишется в stderr и портит логи);-S(--show-error): но при ошибке всё же показать её текст (в паре с-s);--max-time 3: не ждать дольше трёх секунд, иначе код 28. Без потолка зависший сервис зависит и твой скрипт навсегда;-o /dev/null: выбросить тело ответа, если нужен только код (/dev/nullэто «чёрная дыра», урок 1.2).
Комбинация curl -fsS --max-time 3 URL это стандартная строка проверки. Как это выглядит на практике:
сервис curl без -f curl -f
──────────────────────────────────────────────────────────────────────────
выключен код 7 код 7
жив, 200 код 0 код 0
жив, ответ 404 код 0 (!) код 22
жив, но завис ждёт бесконечно код 28 через --max-time
Три реальных случая с «Заметками»:
$ curl -s http://127.0.0.1:8080/healthz; echo "код=$?" # сервис выключен
код=7 <- -s спрятал текст ошибки, остался только код
$ curl -sS http://127.0.0.1:8080/healthz; echo "код=$?"
curl: (7) Failed to connect to 127.0.0.1 port 8080 after 0 ms: Couldn't connect to server
код=7
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/nope; echo "код=$?"
404 <- сервер ответил 404, -w напечатал код ответа
код=0 <- но curl без -f считает, что всё хорошо
Ключ -w '%{http_code}\n' (write-out) печатает после запроса HTTP-код ответа; он полезен, когда надо именно посмотреть код, а не поймать ошибку. И проверка таймаута: если порт открыт, но сервис не отвечает, curl -fsS --max-time 1 вернёт curl: (28) Operation timed out after 1006 milliseconds with 0 bytes received и код 28.
Порт (port) напомним: число, которое указывает, какой программе на машине адресован запрос (урок 1.4); у «Заметок» это 8080. Слово «отказано» в соединении (Connection refused) значит: на этом порту никто не слушает. В новых версиях curl (8.18 в Ubuntu 26.04) фраза в ошибке чуть другая: Could not connect to server вместо Couldn't connect to server.
Прикинь сам: сервис выключен. Что вернёт
curl -s http://127.0.0.1:8080/healthz; echo $?? А с-f, если сервис запущен, но путь неверный?
В первом случае 7: соединиться не удалось. Во втором с -f будет 22: сервер ответил 404, и -f превращает 4xx и 5xx в ненулевой код.
Осторожно: Что -f «чинит» сервис или скрывает ошибку. -f лишь превращает 4xx и 5xx в ненулевой код. И что -s без -S безопасен: тогда при ошибке молчание полное, и ты узнаешь о ней только по коду выхода.
Главное:
-fпревращает ошибки HTTP в код 22,-sубирает индикатор,-Sвозвращает сообщение об ошибке, а--max-timeне даёт ждать вечно.
Проверь понимание: сервис выключен. Что вернёт
curl -s http://127.0.0.1:8080/healthz; echo $?? А с ключом-f, если сервис запущен, но путь неверный?
Ответ
Первое: curl не соединился и вернёт код 7 (вывода нет, -s скрыл сообщение). Второе: сервер ответит 404, и curl -f вернёт код 22. Без -f был бы 0 даже на 404.
Скрипт проверок готов. Дадим командам проекта короткие имена: это make.
Make и Makefile: цели, зависимости и TAB
Скрипты решают «как сделать», но у проекта десяток типовых действий: запустить, проверить, собрать, задеплоить. Каждый разработчик помнит их по-своему. make даёт общий вход: make test работает одинаково у всех, а список действий лежит в одном файле рядом с кодом.
Пульт с подписанными кнопками. Кнопка называется целью (target), а под ней написано, какие действия она запускает. Оговорка: изначально make придумали для сборки программ на C, где он сравнивает даты файлов и пересобирает только изменённое. Мы используем его как пульт, и эта «сборочная» часть нам мешает (см. .PHONY).
make (GNU Make) при запуске читает файл с именем Makefile в текущем каталоге. Файл состоит из правил (rules) такого вида:
цель: зависимости
<TAB>команда 1
<TAB>команда 2
- Цель (target): имя, которое ты вводишь после
make:make test. - Зависимости (prerequisites): что должно быть готово до запуска цели. Это другие цели (или файлы). Они выполняются первыми.
- Рецепт (recipe): команды под целью. Каждая строка рецепта обязана начинаться с символа табуляции (TAB, клавиша Tab). Пробелы вместо TAB дают ошибку
Makefile:13: *** missing separator. Stop.Это самая частая беда новичков: редактор молча заменил TAB четырьмя пробелами.
make lint
│
▼
1. читает Makefile из текущего каталога
2. находит цель «lint:»
3. выполняет её зависимости (если они есть)
4. каждую строку рецепта запускает в НОВОЙ оболочке (sh -c "..."):
• выводит команду на экран (кроме строк с @ в начале)
• если команда вернула не 0, make останавливается и сам выходит с кодом 2
Переменные в Makefile задаются так же, как в bash, но подставляются иначе: $(PORT) в круглых скобках. Оператор ?= значит «присвой, только если переменная ещё не задана»: PORT ?= 8080 берёт значение из окружения, если оно там есть. Значение из командной строки (make run PORT=9000) побеждает всё.
Три особенности, которые надо знать:
- Каждая строка рецепта выполняется в отдельной оболочке.
cd appна одной строке иpython3 app.pyна следующей запустят программу не в каталогеapp:cdумер вместе с первой оболочкой. Пиши в одной строке:cd app && python3 app.py. - Знак
$уmakeтоже особенный, поэтому для оболочки его удваивают:$$HOME. Одиночный$(X)это переменная Make. @перед командой отключает её вывод: без него make сперва печатает саму команду, потом результат.
Зачем .PHONY. make по своей природе сравнивает цель с файлом на диске: если файл test уже существует и зависимостей нет, цель считается свежей, и команды не запускаются: make: 'test' is up to date. Слово phony («ненастоящий») в строке .PHONY: run test lint сообщает: это имена действий, а не файлов, выполняй всегда.
Вот Makefile, который ты соберёшь в задании 5, с разбором, а ниже сбой из-за пробелов вместо TAB. Настоящий вывод cat -A (ключ -A показывает невидимые символы: TAB как ^I, конец строки как $):
$ cat -A Makefile | sed -n 8,12p
PORT=$(PORT) NOTES_DATA=$(NOTES_DATA) $(PYTHON) app.py$ <- в строке TAB (в терминале он виден как ^I)
test: ## ...$
$(PYTHON) -m unittest -v$ <- четыре пробела: ошибка
При таких пробелах: Makefile:13: *** missing separator. Stop. (номер 13 у тебя будет по номеру строки с ошибкой). Для примера с короткой строкой: файл a: и под ней <TAB>echo hi в cat -A выглядит как ^Iecho hi$.
Прикинь сам: в проекте есть файл с именем
test, а вMakefileцельtestбез.PHONY. Что напишетmake test?
make: 'test' is up to date. Файл test уже есть, а зависимостей нет, значит, цель считается готовой и команды не запускаются.
Осторожно: Что make это «сборщик для C». Мы используем его как менеджер команд проекта. И что Makefile это программа на bash: нет, это отдельный язык, в рецептах которого лежат команды оболочки. Различие видно на $: в bash $HOME, в Makefile $$HOME.
flowchart LR
T["make test"] --> F{"Файл test есть<br>и новее зависимостей?"}
F -->|"нет"| R["Выполнить команды цели"]
F -->|"да, без .PHONY"| S["make: 'test' is up to date: команды не запущены"]
P[".PHONY: test"] -->|"всегда считать целью-командой"| R
Главное:
Makefileэто набор «цель: зависимости» и команд с отступом TAB;.PHONYговорит, что цель это команда, а не файл.
Проверь понимание: зачем
.PHONYи что случится, если создать файл с именемtestбез него?
Ответ
Make сравнивает цель с файлом на диске. Файл test существует, зависимостей нет, значит цель «свежая», и команды не запускаются: make: 'test' is up to date. .PHONY говорит: это имя действия, а не файла, выполняй всегда.
Командами управлять научились. Теперь проверим сервис без человека: автотесты.
Автотесты: как проверить сервис без человека
Проверка «Заметок» руками (запустить, набрать curl, посмотреть глазами) занимает минуту и зависит от внимательности. Автоматический тест делает то же за секунды и одинаково каждый раз. Изменил app.py, запустил make test, и через секунду знаешь, не сломал ли контракт (обещанное поведение: что отвечает каждый адрес).
Контрольный чек-лист перед вылетом самолёта: «двигатель, закрылки, шасси». Перечень одинаковый, пилот его не придумывает заново. Оговорка: чек-лист ловит только то, что в него записано; непроверенное поведение тест не гарантирует.
Используем стандартный модуль Python unittest (ставить ничего не надо). Тест это класс, в котором каждый метод с именем test_... проверяет одну вещь. Проверка записывается как «утверждение» (assertion): self.assertEqual(a, b) сообщает, что a и b должны быть равны, иначе тест помечается провалом (FAIL). Итог: OK или FAILED, а код выхода python3 -m unittest равен 0 или 1: то есть тест сам пригоден для make и CI.
Наш тест устроен осторожно, чтобы не мешать работающему сервису:
make test
└─► python3 -m unittest -v
├─ setUpClass (один раз до всех тестов):
│ 1. просит у системы свободный порт (bind на порт 0)
│ 2. создаёт временный каталог для файла данных
│ 3. запускает настоящий app.py как отдельный процесс (subprocess)
│ с HOST=127.0.0.1, PORT=<найденный>, NOTES_DATA=<временный файл>
│ 4. ждёт до 5 секунд, пока сервис начнёт отвечать на /healthz
├─ test_root, test_healthz, test_post_and_get_note, ... (отправляют запросы)
└─ tearDownClass (один раз после): останавливает сервис (SIGTERM),
удаляет временный каталог
Порт 0 это приём: если попросить систему bind на порт 0, она выберет любой свободный. Так тест никогда не конфликтует с боевым сервисом на 8080. Временный каталог (tempfile) нужен, чтобы тест не писал в твой настоящий файл данных.
Реальный запуск: тесты прошли при работающем боевом сервисе на 8080 (Ran 6 tests in 0.650s, у тебя время будет другим), потому что они его не трогают. Если тест падает, он показывает сравнение значений, например AssertionError: 'Notes service vtest\n' != 'Notes service v1\n', где слева фактическое, справа ожидаемое.
Прикинь сам: что покажет
make test, если «Заметки» уже запущены на порту 8080 в другом терминале?
Тесты пройдут: они поднимают собственный сервер на порту 0 (система выберет свободный порт) и с запущенным не конфликтуют.
Осторожно: Что упавший тест значит «сервис сломан». Не всегда: иногда неверен сам тест (ожидание устарело). Правило: тест упал, сначала выясни, чьё поведение правильное, и только потом правь. И ещё: тест, который запускает сервис на 8080, будет ломаться и мешать, поэтому свободный порт и временные данные это обязательная гигиена.
Главное: автотест сам поднимает сервис на свободном порту, делает запросы и проверяет ответ, а упавший тест выясняют: ломался код или устарело ожидание.
Проверь понимание: что покажет
make test, если сервис в этот момент запущен на порту 8080 в другом терминале: тесты упадут или пройдут?
Ответ
Пройдут: тест поднимает свой процесс на свободном порту, выбранном системой, и временный каталог для данных. Боевой сервис на 8080 и его файл данных остаются нетронутыми.
Последнее умение этого урока: быстро прочитать код, которого ты не писал.
Как читать чужой код: app.py за пять минут
Оператору постоянно достаются сервисы, которые писал кто-то другой. Спросить автора не всегда можно, а понять, что можно настроить и куда сервис пишет данные, нужно сразу.
Так читают инструкцию к незнакомой технике: сначала находят разъёмы и кнопки, а не изучают микросхемы. Оговорка: ты не проверяешь, что код правильный, только находишь «внешнюю границу».
Внешних границ у сервиса всегда три, и у каждой есть характерный след в коде:
| Что ищем | Признак в Python | Что это даёт оператору |
|---|---|---|
| Настройки (конфигурация) | os.environ, getenv |
какие переменные окружения читает программа и значения по умолчанию |
| Маршруты (routes) | def do_GET, def do_POST, path == |
какие адреса и методы обслуживает; что проверять curl-ом |
| Хранилище | open(, имя файла или базы |
куда сервис пишет данные, какие права нужны |
Реальные строки из app.py v2.2 (номера строк совпадают с эталоном):
$ grep -n "os.environ" app.py
14:VERSION = os.environ.get("APP_VERSION", "dev")
15:HOST = os.environ.get("HOST", "127.0.0.1")
16:DATA = os.environ.get("NOTES_DATA", "/var/lib/notes/notes.txt")
18: PORT = int(os.environ.get("PORT", "8080"))
23:LOG_LEVEL = os.environ.get("LOG_LEVEL", "info").upper()
56:LEAK_MAX_MB = int(os.environ.get("LEAK_MAX_MB", "1024")) # потолок суммарной утечки
Читаем: os.environ.get("HOST", "127.0.0.1") значит «возьми переменную HOST, а если её нет, то 127.0.0.1». Всего шесть настроек. Из этих строк видно, что по умолчанию сервис слушает 127.0.0.1:8080 и пишет в /var/lib/notes/notes.txt.
Прикинь сам: по строке
PORT = int(os.environ.get("PORT", "8080"))скажи, какой порт будет без переменнойPORTи что случится приPORT=abc?
Порт 8080: это значение по умолчанию. При PORT=abc int("abc") упадёт с ValueError, и сервис не запустится.
Осторожно: Что настройки видны только в документации. Часто документации нет, и первая правда это код. И наоборот: что нужно понимать весь код. Нет, достаточно трёх границ.
Главное: чужой код читают сверху вниз по пяти признакам: настройки (
os.environ), маршруты, хранение, обработку ошибок и запуск.
Проверь понимание: по строке
PORT = int(os.environ.get("PORT", "8080"))скажи, на каком порту запустится сервис, если переменнаяPORTне задана, и что случится приPORT=abc.
Ответ
На 8080: это значение по умолчанию. При PORT=abc int("abc") вызовет ValueError, и в app.py это перехвачено: сообщение ошибка: PORT должен быть числом и выход с кодом 2 (строки 17-21 файла).
Теория закончена. В практике ты напишешь скрипт проверки, Makefile и тесты «Заметок».
Практика
Работай в ~/notes (там лежит app.py версии v2.2 из прошлых уроков) и в отдельном каталоге ~/lab16 для учебных скриптов. Bash 5.x есть в Ubuntu из коробки, а GNU Make нет: на чистой Ubuntu 24.04 команда make отвечает bash: make: command not found (код 127). Поставь его:
sudo apt install -y make
make --version | head -1
Разбор: sudo apt install -y make ставит пакет make без вопросов (-y это «да на все вопросы», урок 1.1). make --version печатает версию, а | head -1 оставляет первую строку (конвейер, урок 1.2).
GNU Make 4.3
Как читать вывод: важна только первая строка: программа GNU Make, версия 4.3 (на Ubuntu 26.04 будет 4.4.1). Всё, что мы используем, работает в обеих.
Чтобы не мешать данным, боевой сервис в заданиях запускаем с файлом данных во временной папке: NOTES_DATA=/tmp/notes-lab16.txt.
Задание 1. Переменные и кавычки: ломаем и чиним
Цель: увидеть, как кавычки и пробелы меняют поведение скрипта.
Предскажи: что выведет ls -l ${FILE} при FILE="my notes.txt" без кавычек? Сколько аргументов получит ls?
Ответ
Два: my и notes.txt. ls скажет, что таких файлов нет.
Шаги:
- Создай каталог
~/lab16и файл с пробелом в имени. Разбор команд:mkdir -pсоздаёт каталог (и не ругается, если он есть),&&выполняет следующую команду только при успехе,cdпереходит в каталог,echo "тест" > "my notes.txt"записывает слово в файл (>из урока 1.2),ls -lпоказывает файл подробно.
mkdir -p ~/lab16 && cd ~/lab16
echo "тест" > "my notes.txt"
FILE="my notes.txt"
ls -l ${FILE} # без кавычек: плохо
ls -l "${FILE}" # с кавычками: хорошо
- Проверь границу окружения из теории. Команда
bash -c '...'запускает дочерний bash с текстом в кавычках как программой:
X=1
bash -c 'echo "в дочернем: [$X]"'
export X
bash -c 'echo "в дочернем: [$X]"'
Y=5 bash -c 'echo "разово: [$Y]"'; echo "после: [$Y]"
- Сохрани скрипт
greet.sh. Разбор:cat > greet.sh <<'SCRIPT' ... SCRIPTэто here-документ (here-doc): всё между двумяSCRIPTзаписывается в файл; одинарные кавычки вокруг'SCRIPT'запрещают подстановки внутри, поэтому$#и${1}попадут в файл буквально, а не раскроются сейчас. Затемchmod +xдаёт файлу бит исполнения. Сначала попробуй запустить файл без него, чтобы увидеть ошибку:
cat > greet.sh <<'SCRIPT'
#!/usr/bin/env bash
# Приветствие: имя из первого аргумента или из переменной USER_NAME
NAME="${1:-${USER_NAME:-мир}}"
echo "Привет, ${NAME}! Аргументов: $#"
SCRIPT
./greet.sh # ещё нет бита исполнения
chmod +x greet.sh
ls -l greet.sh
./greet.sh
./greet.sh Анна
USER_NAME=Борис ./greet.sh
Что должно получиться (реальный вывод; время и права в ls -l у тебя могут отличаться: права зависят от umask, о нём в уроке 1.3):
ls: cannot access 'my': No such file or directory
ls: cannot access 'notes.txt': No such file or directory
-rw-r--r-- 1 ubuntu ubuntu 9 Sep 30 11:53 my notes.txt
в дочернем: []
в дочернем: [1]
разово: [5]
после: []
bash: ./greet.sh: Permission denied
-rwxr-xr-x 1 ubuntu ubuntu 224 Sep 30 11:53 greet.sh
Привет, мир! Аргументов: 0
Привет, Анна! Аргументов: 1
Привет, Борис! Аргументов: 0
Как читать вывод: первые две строки это ошибки ls на два «разрезанных» слова. Третья строка это обычный вывод ls -l: права -rw-r--r--, владелец, размер 9 байт (слово «тест» по-русски занимает 8 байт в UTF-8, плюс перенос строки), дата и имя. Три строки в дочернем/разово/после подтверждают правило видимости. Permission denied это отсутствие бита исполнения, а в следующей строке видна буква x в правах (-rwxr-xr-x). Три последние строки показывают приоритет значений.
Объясни себе:
- Почему при
USER_NAME=Борис ./greet.shчисло аргументов 0, а имя всё равно подставилось? - Чем
${1:-...}отличается от$1?
Типичные ошибки:
bash: ./greet.sh: Permission denied: нет бита исполнения:chmod +x greet.sh.bash: NAME: command not found: написалNAME = "мир"с пробелами. Bash прочиталNAMEкак команду. Убери пробелы вокруг=./usr/bin/env: 'bash\r': No such file or directory: в конце первой строки лишний символ\r(файл из Windows). Пересохрани файл с окончаниями строк LF, в редакторе это настройка «Line endings».bash: greet.sh: command not found: написалgreet.shбез./. Текущего каталога нет вPATH.
Не понимаешь, почему скрипт ведёт себя странно? Дай нейросети текст скрипта, команду запуска и вывод. Попроси объяснить построчно, где значение разрежется пробелами и какой код вернёт каждая команда. Потом проверь
bash -nиshellcheck.
Задание 2. Условия и коды выхода: скрипт проверки
Цель: написать check.sh, который сообщает состояние сервиса и честно возвращает код выхода.
Предскажи: сервис не запущен. Что вернёт curl -s http://127.0.0.1:8080/healthz; echo $?? А что будет с -f, если сервис запущен, но путь неверный?
Ответ
Сервис выключен: curl не соединился и вернёт код 7 (вывода нет). Путь неверный при -f: сервер ответит 404, curl -f вернёт код 22. Без -f был бы 0 даже на 404.
Шаги:
- В одном терминале запусти сервис. Разбор:
cd ~/notesпереходит в проект,NOTES_DATA=/tmp/notes-lab16.txtзадаёт файл данных только для этого запуска (урок 1.4),python3 app.pyзапускает сервис, который занимает терминал.
cd ~/notes && NOTES_DATA=/tmp/notes-lab16.txt python3 app.py
- Во втором терминале создай скрипт. Как он устроен: значения
HOSTиPORTберутся из окружения или по умолчанию (${HOST:-127.0.0.1});if [ "$#" -gt 1 ]проверяет, что аргументов не больше одного, и при нарушении пишет справку в stderr (>&2) и выходит с кодом 2;curl ... >/dev/nullвыбрасывает тело ответа, потому что нужен только код;--max-time 3ограничивает ожидание;if curl ...; thenберёт кодcurlкак условие.
cd ~/lab16
cat > check.sh <<'SCRIPT'
#!/usr/bin/env bash
# Проверка "Заметок": 0 живой, 1 не отвечает, 2 неверные аргументы
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8080}"
if [ "$#" -gt 1 ]; then
echo "Использование: $0 [путь]" >&2
exit 2
fi
URL_PATH="${1:-/healthz}"
if curl -fsS --max-time 3 "http://${HOST}:${PORT}${URL_PATH}" >/dev/null; then
echo "OK ${URL_PATH}"
else
echo "FAIL ${URL_PATH}" >&2
exit 1
fi
SCRIPT
chmod +x check.sh
./check.sh; echo "код=$?"
./check.sh /nope; echo "код=$?"
./check.sh a b; echo "код=$?"
- Останови сервис (
Ctrl+Cв первом терминале) и запусти./check.shещё раз.
Что должно получиться (реальный вывод, curl 8.5 из Ubuntu 24.04):
OK /healthz
код=0
curl: (22) The requested URL returned error: 404
FAIL /nope
код=1
Использование: ./check.sh [путь]
код=2
curl: (7) Failed to connect to 127.0.0.1 port 8080 after 0 ms: Couldn't connect to server
FAIL /healthz
Как читать вывод: OK ... код=0 значит сервис ответил 200. Вторая группа: curl: (22) пишет сам curl (ошибка 404 превращена ключом -f в код 22), а FAIL /nope и код=1 уже наши слова и наш код. Третья: справка и код 2. Четвёртая: сервис остановлен, curl: (7) (нет соединения). В Ubuntu 26.04 (curl 8.18) вместо Couldn't connect to server будет Could not connect to server. Кодов выхода у последнего запуска на экране нет: проверь echo $? сам, там будет 1.
Убедись, что скрипт действительно делает то, что задумано, режимом трассировки: bash -x печатает каждую команду со знаком + перед выполнением. Запусти bash -x ./check.sh при выключенном сервисе:
+ HOST=127.0.0.1
+ PORT=8080
+ '[' 0 -gt 1 ']'
+ URL_PATH=/healthz
+ curl -fsS --max-time 3 http://127.0.0.1:8080/healthz
curl: (7) Failed to connect to 127.0.0.1 port 8080 after 0 ms: Couldn't connect to server
+ echo 'FAIL /healthz'
FAIL /healthz
+ exit 1
Здесь виден каждый шаг: подстановки уже выполнены ('[' 0 -gt 1 ']' это твой [ "$#" -gt 1 ] при нуле аргументов), curl вернул не 0, и выполнилась ветка else. Именно так «ничего не сделал, а код 0» ищут в чужих скриптах.
Объясни себе:
- Почему сообщения об ошибках направлены в
>&2? (вспомни урок 1.2) - Зачем
--max-time 3, что было бы при зависшем сервисе без него?
Типичные ошибки:
./check.sh: line 8: [: too many arguments: переменная без кавычек в[ ], а в ней пробелы: возьми в кавычки../check.sh: line 12: syntax error near unexpected token 'fi': пропущеноthenили;перед ним: сверь с шаблоном. Если не хватаетfi, будетsyntax error: unexpected end of file.- Скрипт всегда печатает
OK, даже когда сервис отвечает 404: забыл-fуcurl. ./check.sh: Permission denied: нетchmod +x check.sh.
Задание 3. Циклы: ждём сервис и проверяем маршруты
Цель: типовой цикл оператора: «ждать, пока сервис поднимется».
Предскажи: сервис выключен. Сколько секунд проработает скрипт ниже и с каким кодом выйдет?
Ответ
Около 10 секунд (10 попыток по секунде; curl при отказе в соединении срабатывает мгновенно) и код 1. Если сервис поднимется раньше, выход с кодом 0 на этой попытке.
Шаги:
- Скрипт ожидания с ограниченным числом попыток (про
set -eи защиту скриптов подробнее в уроке 1.7). Разбор:for i in $(seq 1 10)даёт десять проходов;>/dev/null 2>&1выбрасывает и stdout, и stderr (порядок важен, урок 1.2), чтобы цикл не засорял экран;exit 0завершает скрипт сразу при успехе; после циклаecho ... >&2иexit 1означают «не дождались».
cd ~/lab16
cat > wait.sh <<'SCRIPT'
#!/usr/bin/env bash
# Ждём, пока сервис ответит на /healthz, не больше 10 попыток
for i in $(seq 1 10); do
if curl -fsS --max-time 1 http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
echo "поднялся с попытки ${i}"
exit 0
fi
echo "попытка ${i}: пока нет"
sleep 1
done
echo "не дождались" >&2
exit 1
SCRIPT
chmod +x wait.sh
- Замерь работу при выключенном сервисе, потом повтори: во время ожидания в другом терминале запусти
cd ~/notes && NOTES_DATA=/tmp/notes-lab16.txt python3 app.py. Командаtimeпечатает, сколько времени заняла следующая за ней команда.
time ./wait.sh; echo "код=$?"
./wait.sh; echo "код=$?"
Что должно получиться (реальный вывод; первая группа, сервис выключен всё время):
попытка 1: пока нет
...
попытка 10: пока нет
не дождались
real 0m10.050s
user 0m0.021s
sys 0m0.020s
код=1
Вторая группа, сервис запущен во втором терминале на третьей секунде:
попытка 1: пока нет
попытка 2: пока нет
попытка 3: пока нет
поднялся с попытки 4
код=0
Как читать вывод: real это реальное время (10 секунд: десять sleep 1), user и sys это процессорное время, оно ничтожно, потому что скрипт почти всё время спит. Число попыток во втором случае зависит от того, как быстро ты запустил сервис: у тебя будет другое.
Объясни себе:
- Чем
wait.shлучше, чемsleep 10? - Что изменится, если убрать
exit 0внутриif?
Типичные ошибки:
./wait.sh: line 4: seq: command not found: редкий урезанный образ без пакетаcoreutils: замени наfor i in 1 2 3 4 5.- Цикл
for i in {1..$N}печатает{1..3}буквально: раскрытие фигурных скобок не подставляет переменные, используйseq 1 "${N}". - Скрипт «ждёт» бесконечно: в цикле забыт потолок или у
curlнет--max-time.
Задание 4. Читаем app.py
Цель: за пять минут понять чужой сервис по командам grep.
Предскажи: какие переменные окружения читает app.py и сколько маршрутов у него в do_GET? Запиши догадку и сверь.
Шаги: команды grep из урока 1.2: -n печатает номер строки, \| внутри шаблона значит «или», wc -l считает строки.
cd ~/notes
grep -n "os.environ" app.py # конфигурация
grep -n "def do_\|parsed.path\|path ==" app.py # методы и маршруты
grep -n "open(\|NOTES_DATA" app.py # где хранятся данные
wc -l app.py # размер файла
Что должно получиться (реальный вывод для v2.2):
14:VERSION = os.environ.get("APP_VERSION", "dev")
15:HOST = os.environ.get("HOST", "127.0.0.1")
16:DATA = os.environ.get("NOTES_DATA", "/var/lib/notes/notes.txt")
18: PORT = int(os.environ.get("PORT", "8080"))
23:LOG_LEVEL = os.environ.get("LOG_LEVEL", "info").upper()
56:LEAK_MAX_MB = int(os.environ.get("LEAK_MAX_MB", "1024")) # потолок суммарной утечки
77: def do_GET(self):
81: if parsed.path == "/leak":
91: if parsed.path == "/burn":
102: path = parsed.path
103: if path == "/":
105: elif path == "/healthz":
107: elif path == "/readyz":
111: elif path == "/notes":
120: def do_POST(self):
139: def do_PUT(self):
16:DATA = os.environ.get("NOTES_DATA", "/var/lib/notes/notes.txt")
37: with open(DATA, encoding="utf-8") as f:
50: with open(DATA, "a", encoding="utf-8") as f:
108: ok = os.access(os.path.dirname(DATA), os.W_OK)
185 app.py
Как читать вывод: число слева это номер строки в файле. Шесть переменных: APP_VERSION, HOST, NOTES_DATA, PORT, LOG_LEVEL, LEAK_MAX_MB. Строка 139:def do_PUT показывает, что PUT обработан отдельно (ответ 405), а do_GET содержит шесть маршрутов: /leak, /burn, /, /healthz, /readyz, /notes; do_POST только /notes. Строка 50 (open(DATA, "a"), режим a это дописывание в конец) единственная, которая пишет данные, а 37 читает. Номера строк у тебя совпадут, если файл взят из эталона; иначе важны сами найденные строки. STORE (выбор между файлом и PostgreSQL) в v2.2 ещё нет: он появится в уроке 4.4.
Запиши результат в таблицу из трёх столбцов: переменная, значение по умолчанию, за что отвечает. Ответ для сверки: HOST (127.0.0.1, адрес прослушивания), PORT (8080), APP_VERSION (dev, версия в ответе /), LOG_LEVEL (info), NOTES_DATA (/var/lib/notes/notes.txt), LEAK_MAX_MB (1024, потолок памяти для /leak).
Что должно получиться: ты можешь ответить письменно на три вопроса без запуска: на каком адресе и порту слушает сервис по умолчанию (127.0.0.1:8080), в каком файле лежат данные (/var/lib/notes/notes.txt), какие маршруты есть.
Объясни себе:
- Почему по умолчанию
HOST=127.0.0.1, а не0.0.0.0? - Что произойдёт, если запустить сервис под пользователем без доступа к
NOTES_DATA? (вспомни урок 1.3)
Типичные ошибки:
grep: app.py: No such file or directory: ты не в~/notes:cd ~/notes.- Пустой вывод
grep: в твоей версии другое имя (environ.get,getenv): ищиgrep -n "environ\|getenv" app.py.
Пишешь
Makefileс нуля? Попроси нейросеть объяснить, что значит каждая строка, и особо проверь TAB в начале команд. Потом запустиmake -nи посмотри, чтоmakeсобирается выполнить.
Задание 5. Шаг проекта: Makefile и тесты «Заметок»
Цель: make run, make test, make lint в ~/notes.
Предскажи: что покажет make test, если сервис в этот момент запущен на порту 8080 в другом терминале: тесты упадут или пройдут?
Ответ
Пройдут: тест поднимает свой процесс на свободном порту, выбранном системой, и временный каталог для данных. Ваш «боевой» сервис на 8080 и его файл данных остаются нетронутыми.
Шаги:
- Создай пустой
requirements.txt. Это список внешних библиотек Python, которые нужны проекту (обычно его читаетpip install -r requirements.txt, установщик библиотек). У «Заметок» зависимостей пока нет (только стандартная библиотека), файл нужен для будущих уроков. Команда: > файлсоздаёт пустой файл (:это команда «ничего не делать», а>перенаправляет её пустой вывод в файл).
cd ~/notes
: > requirements.txt
- Создай
Makefile. Отступ перед командами это символ TAB, а не пробелы: вnanoиvimнажми клавишу Tab, а если копируешь текст отсюда, проверь результат командойcat -A Makefile(в строках команд должно быть^I).
# Цели проекта "Заметки". Запуск: make <цель>
PYTHON ?= python3
PORT ?= 8080
# файл данных для make run: в /tmp, чтобы не нужны были права на /var/lib/notes
NOTES_DATA ?= /tmp/notes-dev.txt
.PHONY: run test lint
run: ## запустить сервис на 127.0.0.1:$(PORT)
PORT=$(PORT) NOTES_DATA=$(NOTES_DATA) $(PYTHON) app.py
test: ## прогнать unit-тесты
$(PYTHON) -m unittest -v
lint: ## проверить синтаксис (скелет; настоящий линтер в уроке 3.3)
$(PYTHON) -m py_compile app.py test_app.py
@echo "lint: синтаксис в порядке"
Разбор построчно:
PYTHON ?= python3,PORT ?= 8080,NOTES_DATA ?= ...: переменные Make.?=задаёт значение, только если оно ещё не задано (например, черезmake run PORT=9000или окружение). Такую переменную удобно менять, не правя файл..PHONY: run test lint: эти три имени действия, а не файлы.run:цель без зависимостей. Часть## запустить...после неё это комментарий (в Make комментарии начинаются с#, двойной##соглашение для «справки» по целям). КомандаPORT=$(PORT) NOTES_DATA=$(NOTES_DATA) $(PYTHON) app.pyподставляет значения переменных Make и передаётPORTиNOTES_DATAв окружение сервиса (приём «переменная перед командой» из теории).test:запускаетpython3 -m unittest -v: модульunittestсам находит файлыtest*.pyв текущем каталоге, ключ-vпечатает каждый тест.lint:(lint, «линтер»: программа, которая проверяет код, не запуская его). Пока это скелет:py_compileкомпилирует файлы в байт-код и ловит синтаксические ошибки (лишняя скобка, забытое двоеточие); настоящий линтер (ruff) появится в уроке 3.3.@echoс@печатает сообщение без показа самой команды.
- Создай
test_app.py. Сначала прочитай, что в нём (полный разбор ниже кода):
"""Тесты сервиса "Заметки": запускаем настоящий app.py на свободном порту."""
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
APP = Path(__file__).with_name("app.py")
def free_port():
# Просим систему выдать любой свободный порт
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class NotesTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.port = free_port()
env = dict(os.environ)
env.update(
HOST="127.0.0.1",
PORT=str(cls.port),
APP_VERSION="test",
NOTES_DATA=str(Path(cls.tmp.name) / "notes.txt"),
)
cls.proc = subprocess.Popen(
[sys.executable, str(APP)],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Ждём до 5 секунд, пока сервер начнёт отвечать
for _ in range(50):
try:
cls.call("GET", "/healthz")
return
except OSError:
time.sleep(0.1)
cls.tearDownClass()
raise RuntimeError("сервис не поднялся за 5 секунд")
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=15)
cls.tmp.cleanup()
@classmethod
def call(cls, method, path, body=None):
req = urllib.request.Request(
f"http://127.0.0.1:{cls.port}{path}", data=body, method=method
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, resp.read().decode()
except urllib.error.HTTPError as err:
with err: # закрываем ответ с ошибкой, иначе Python 3.14 ругается предупреждением
return err.code, err.read().decode()
def test_root(self):
status, text = self.call("GET", "/")
self.assertEqual(status, 200)
self.assertEqual(text, "Notes service vtest\n")
def test_healthz(self):
status, _ = self.call("GET", "/healthz")
self.assertEqual(status, 200)
def test_post_and_get_note(self):
payload = json.dumps({"text": "первая заметка"}).encode()
status, _ = self.call("POST", "/notes", payload)
self.assertEqual(status, 201)
status, text = self.call("GET", "/notes")
self.assertEqual(status, 200)
self.assertIn("первая заметка", [n["text"] for n in json.loads(text)])
def test_not_found(self):
status, _ = self.call("GET", "/no-such-path")
self.assertEqual(status, 404)
def test_empty_body_is_400(self):
status, _ = self.call("POST", "/notes", b"")
self.assertEqual(status, 400)
def test_method_not_allowed(self):
status, _ = self.call("PUT", "/notes", b"{}")
self.assertEqual(status, 405)
if __name__ == "__main__":
unittest.main()
Как читать этот файл (код на Python, но схема из теории «настройки, маршруты, хранилище» здесь не нужна, важен порядок):
- Первые строки
import ...подключают стандартные модули:socket(сети),subprocess(запуск других программ),tempfile(временные файлы),urllib(HTTP-запросы),unittest(сами тесты). free_port()открывает сокет на порту 0 и спрашивает, какой порт выдала система (getsockname()[1]).setUpClassвыполняется один раз до тестов: создаёт временный каталог, берёт свободный порт, собираетenv(копию окружения плюсHOST,PORT,APP_VERSION=test,NOTES_DATAво временном файле) и запускаетapp.pyчерезsubprocess.Popen(запуск отдельного процесса;sys.executableэто тот жеpython3, которым идёт тест). Дальше цикл до 50 раз по 0,1 с ждёт первого ответа/healthz; не дождался: ошибка «сервис не поднялся за 5 секунд».tearDownClassв конце:terminate()посылает процессу SIGTERM (урок 1.4), ждёт его выхода и удаляет временный каталог.callотправляет запрос и возвращает пару «код, текст ответа»; ошибки 4xx и 5xx Python поднимает как исключениеHTTPError, поэтому они перехвачены и тоже превращены в пару.- Шесть методов
test_...это шесть проверок контракта: корень отвечаетNotes service vtest(потому что тест запускает сервис сAPP_VERSION=test),/healthzдаёт 200, POST создаёт заметку (201) и она появляется в GET, неизвестный путь даёт 404, пустое тело POST даёт 400,PUTдаёт 405.
- Запусти:
make test
make lint
make run PORT=9000 # остановка: Ctrl+C
Что должно получиться (реальный вывод; Ran 6 tests in 0.650s: время у тебя будет другим):
python3 -m unittest -v
test_empty_body_is_400 (test_app.NotesTest.test_empty_body_is_400) ... ok
test_healthz (test_app.NotesTest.test_healthz) ... ok
test_method_not_allowed (test_app.NotesTest.test_method_not_allowed) ... ok
test_not_found (test_app.NotesTest.test_not_found) ... ok
test_post_and_get_note (test_app.NotesTest.test_post_and_get_note) ... ok
test_root (test_app.NotesTest.test_root) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.650s
OK
python3 -m py_compile app.py test_app.py
lint: синтаксис в порядке
PORT=9000 NOTES_DATA=/tmp/notes-dev.txt python3 app.py
2026-09-30 11:53:58,015 INFO started host=127.0.0.1 port=9000
После Ctrl+C в терминале с make run появятся строки shutting down, stopped (это твой обработчик сигналов из урока 1.4) и, возможно, make: *** [Makefile:10: run] Interrupt: make сообщает, что его тоже прервали, это нормально.
Как читать вывод: первая строка python3 -m unittest -v это команда, которую make печатает перед выполнением (её печать отключает @). Дальше по строке на тест: имя, класс и ok. Строка из дефисов, Ran 6 tests и итог OK (при провале будет FAILED (failures=1) и подробности выше). Во втором блоке lint напечатал команду и своё сообщение (@echo сам себя не показал, только результат). Третий блок: команда run со значениями, подставленными Make (видно PORT=9000), и лог сервиса. Файл __pycache__ после make lint создан самим Python (кэш байт-кода), его можно игнорировать.
Если твой app.py отвечает на какой-то случай иначе, чем описано (например, PUT даёт 501), тест это покажет: это не ошибка теста, а расхождение с контрактом сервиса, которое нужно исправить в app.py. Эталон проекта: https://github.com/distinguished-sre/learning/tree/main/devops/project/notes.
Объясни себе:
- Почему тесты запускают сервис на свободном порту и во временном каталоге, а не на 8080?
- Зачем перед командой
lintнет@, а передechoесть? (что печатает Make по умолчанию) - Что даст пустой
requirements.txt, если его сегодня никто не читает?
Типичные ошибки:
Makefile:13: *** missing separator. Stop.: в строке команды пробелы вместо TAB (номер строки у тебя свой): замени отступ на TAB.make: *** No rule to make target 'tests'. Stop.: опечатка в имени цели:make test.make: 'test' is up to date.: нет.PHONYи рядом лежит файл или каталогtest: добавь.PHONY.RuntimeError: сервис не поднялся за 5 секунд:app.pyпадает при старте: запустиpython3 app.pyруками и прочитай ошибку.OSError: [Errno 98] Address already in useиmake: *** [Makefile:10: run] Error 1: приmake runпорт занят другим сервисом (например, боевым на 8080):make run PORT=9001. Traceback выше этой строки длинный, читай последние строки.bash: make: command not found: make не установлен, см. начало практики.
Сломай и почини
Скачай сломанное окружение и не читай скрипт (иначе пропадёт смысл упражнения). Он аккуратно испортит файлы в ~/notes; сначала сделай копию (-a сохраняет права и даты): cp -a ~/notes ~/notes.before-break. Скрипт запускай без sudo: он правит твои файлы, и от root их владелец поменялся бы.
curl -fsSL -o /tmp/break-1.6.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/1.6/break.sh
bash /tmp/break-1.6.sh 1 # сценарий 1, 2 или 3; вернуть как было: bash /tmp/break-1.6.sh fix
Симптом
После запуска сценария make test или make run не работает так, как в задании 5. Пойди по порядку: прочитай точный текст ошибки, определи, кто ругается (Make, оболочка, Python или unittest).
- Сценарий 1:
make testсразу пишет ошибку в первой строке и не запускает ни одного теста. - Сценарий 2:
make testзапускает тесты, но один из шести падает. - Сценарий 3:
make run PORT=9000пытается запустить сервис и сразу завершается ошибкой.
Гипотезы
- Make не понимает файл: синтаксис или невидимые символы.
- Сервис и Makefile в порядке, ошибаются тесты.
- Makefile работает, но передаёт сервису не то значение.
Проверки
cd ~/notes
make test 2>&1 | head -20 # первая строка ошибки самая важная
cat -A Makefile | sed -n 1,20p # TAB виден как ^I, пробелы как пробелы
diff -u ~/notes.before-break/Makefile Makefile
diff -u ~/notes.before-break/test_app.py test_app.py
make -n run # -n показывает команды, не выполняя их
make --warn-undefined-variables -n run # предупреждает об обращении к незаданной переменной
Ключ -n (dry run): показывает команды, но не запускает; diff -u сравнивает два файла и показывает различающиеся строки со знаками - (было) и + (стало).
Исправление
Разбор сценариев
Сценарий 1. Makefile:13: *** missing separator. Stop. (make не запустил ни одной команды). В cat -A строка команды у test начинается с пробелов, а у остальных ^I. Вернуть TAB в начале строки команды (в nano и vim это клавиша Tab, редактор мог заменить её пробелами). Урок: невидимые символы находи через cat -A.
Сценарий 2. Make отработал, но unittest печатает FAIL: test_root и:
AssertionError: 'Notes service vtest\n' != 'Notes service v1\n'
Тест ждёт не то, что сервис возвращает по контракту. Проверь руками curl -i и реши, кто прав: тест или сервис. Тест запускает сервис с APP_VERSION=test, значит правильный ответ Notes service vtest, и неверно ожидание в тесте: его и исправь. Правило: тест падает, сначала выясни, чьё поведение верное, и только потом правь.
Сценарий 3. make run не запускает сервис на порту 9000: в make -n run видна команда PORT= NOTES_DATA=/tmp/notes-dev.txt python3 app.py с пустым значением. Сервис завершается с сообщением ошибка: PORT должен быть числом и кодом 2 (пустая строка не число), а Make добавляет make: *** [Makefile:10: run] Error 2. Причина: в Makefile опечатка $(PROT) вместо $(PORT), а Make молча подставляет пустую строку вместо незаданной переменной (как и bash). Исправь PROT на PORT. Для поиска таких ошибок: make -n покажет итоговую команду, make --warn-undefined-variables напечатает warning: undefined variable 'PROT'.
После исправления убедись, что make test зелёный (шесть тестов, OK), а diff -r ~/notes.before-break ~/notes -x __pycache__ ничего не выводит. Потом удали /tmp/break-1.6.sh и ~/notes.before-break. Если запутался, bash /tmp/break-1.6.sh fix возвращает всё, и запускать его можно повторно.
ИИ в помощь
Нейросеть быстро пишет заготовки скриптов и объясняет чужие, но не видит твою оболочку и твои файлы, а ошибки в кавычках и кодах выхода делает чаще всего. Общие правила работы с ней: ИИ-помощник.
Задача: разобрать чужой скрипт.
Вот bash-скрипт: <вставь текст>. Объясни построчно, что он делает, что произойдёт,
если переменная пуста или значение содержит пробел, и какие команды могут что-то удалить или изменить.
Предложи безопасный способ запустить его в первый раз.
Проверь ответ: прогони скрипт через bash -n и shellcheck, а подозрительные места проверь руками на тестовых данных. Типичная ошибка: нейросеть не замечает rm -rf $VAR/* без кавычек и проверки на пустое значение.
Задача: написать скрипт проверки сервиса.
Напиши bash-скрипт check.sh: проверяет http://127.0.0.1:8080/healthz через curl с --max-time 3,
выходит с кодом 0 при ответе 200, 1 при ошибке сервиса, 2 при неверных аргументах.
Используй set -u и кавычки вокруг переменных. Не добавляй ничего лишнего.
Проверь ответ: запусти при работающем и остановленном сервисе и сверь echo $?. Типичная ошибка: скрипт всегда возвращает 0, потому что последней командой стоит echo.
Задача: разобрать ошибку make.
make пишет: <точный текст ошибки>. Вот Makefile: <вставь>. Объясни причину и покажи исправление.
Проверь, не TAB ли это и не нужен ли .PHONY.
Проверь ответ: убедись, что строки команд начинаются с TAB (cat -A Makefile), и запусти make -n. Типичная ошибка: нейросеть возвращает пробелы вместо TAB.
Словарик урока
| Термин | Простыми словами |
|---|---|
| скрипт (script) | текстовый файл с командами, которые оболочка выполняет по порядку |
| shebang | первая строка скрипта #!/usr/bin/env bash: указывает, какой программой его запускать |
| интерпретатор | программа, которая читает текст программы и выполняет его (bash для .sh, python3 для .py) |
| бит исполнения | право «этот файл можно запускать» (chmod +x) |
| переменная | имя, под которым оболочка хранит текстовое значение: PORT=8080 |
| окружение (environment) | переменные, помеченные export, которые получают запускаемые программы |
| дочерний процесс | процесс, запущенный другим процессом (родителем) |
| аргумент | слово после имени команды или скрипта: в ./greet.sh Анна это Анна |
| кавычки | двойные "..." защищают пробелы, но разрешают подстановки; одинарные '...' защищают всё |
| подстановка | замена $(...), ${...}, $((...)) на значение перед выполнением команды |
| glob (шаблон) | * и ?, которые оболочка заменяет списком подходящих файлов |
| код выхода (exit code) | число, которое процесс возвращает при завершении: 0 успех, иное ошибка; лежит в $? |
| here-документ | запись <<'МЕТКА' ... МЕТКА, которая подаёт команде несколько строк текста |
[ ] (test) |
команда проверки условия; возвращает 0 (верно) или 1 (неверно) |
&&, \|\| |
«выполни следующее, только если предыдущая успешна / неуспешна» |
curl -fsS |
проверка по HTTP: -f ошибка при 4xx и 5xx, -s тише, -S но показывай ошибку |
| таймаут | предел ожидания; --max-time 3 у curl |
| make | программа, которая выполняет цели из Makefile |
| цель (target) | имя действия в Makefile: то, что пишут после make |
| рецепт (recipe) | команды под целью; каждая строка начинается с TAB |
.PHONY |
пометка «это имя действия, а не файла» |
| unittest | стандартный модуль Python для автоматических тестов |
| контракт сервиса | что именно и как отвечает каждый адрес; тест проверяет контракт |
| линтер (lint) | программа, проверяющая код без запуска (пока py_compile как скелет) |
requirements.txt |
список внешних библиотек Python для проекта |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Чем одинарные кавычки отличаются от двойных и зачем писать "$var"?
Ответ
В одинарных кавычках текст остаётся как есть: '$HOME' - это просто строка. В двойных подставляются переменные и $(команда): "$HOME". Без кавычек значение переменной делится по пробелам и в нём раскрываются шаблоны вроде *. Поэтому файл my file.txt превращается в два аргумента, а rm -rf $DIR/* при пустой DIR превращается в rm -rf /*. Я всегда пишу "$var", а для обязательных переменных использую ${DIR:?не задан}.
Что хотят услышать: одинарные не подставляют, двойные подставляют, разбиение по пробелам и раскрытие * без кавычек, пример опасной команды
Красный флаг: не ставить кавычки вокруг переменных, потому что «и так работает»
2. [junior] [часто] Чем >, >> и 2>&1 отличаются и что произойдёт при cmd > out.log 2>&1 и cmd 2>&1 > out.log?
Ответ
> перезаписывает файл, >> дописывает, 2>&1 направляет поток ошибок (stderr) туда же, куда идёт обычный вывод (stdout). В первой форме и stdout, и stderr идут в файл. Во второй stderr сначала привязан к старому stdout (терминалу), потом stdout уходит в файл: ошибки останутся на экране. Порядок редиректов важен: они применяются слева направо.
Что хотят услышать: порядок обработки слева направо, дескрипторы 1 и 2, что при 2>&1 в конвейере stderr попадает в pipe.
Красный флаг: «это одно и то же».
3. [junior] [часто] Как узнать, что предыдущая команда завершилась успешно, и как скрипт сообщит об ошибке вызвавшему?
Ответ
Код выхода лежит в $?: 0 успех, иное ошибка. Условия if и &&/|| проверяют именно его. Свой скрипт завершаю exit N; вызывающий (CI, cron, make) остановится или продолжит по этому коду, поэтому по ошибке я выхожу с ненулевым кодом, а не просто печатаю сообщение.
Что хотят услышать: $?, exit, что CI и Make смотрят на код, а не на текст.
Красный флаг: «смотрю, есть ли слово error в выводе».
4. [junior] Скрипт отработал, но «ничего не сделал». В логе пусто, код выхода 0. Что проверишь?
Ответ
Запущу с трассировкой bash -x script.sh: она печатает каждую команду после подстановок, видно, какие ветки реально выполнялись. Проверю условия: часто if [ $VAR = x ] с пустой переменной или curl без -f, который вернул 0 на ответе 404. Посмотрю код выхода промежуточных команд и есть ли в скрипте || true, глушащий ошибки (код превращается в 0).
Что хотят услышать: bash -x, кавычки вокруг переменных, коды выхода, curl -f, подозрение на || true и проглоченный stderr.
Красный флаг: «добавлю sleep» или «перепишу на Python» без диагностики.
5. [junior] Что делает FILE=$1; rm -rf $FILE/*, если запустить без аргумента? Как исправить?
Ответ
FILE пустая, оболочка раскроет команду в rm -rf /*, то есть попытается удалить всё, до чего дотянутся права. Исправление: кавычки и защита от пустого значения rm -rf "${FILE:?нужен каталог}"/*: конструкция :? прерывает скрипт с сообщением. Ещё лучше режим set -u («незаданная переменная это ошибка», см. урок 1.7).
Что хотят услышать: подстановка пустой переменной, ${VAR:?}, set -u, проверка -n.
Красный флаг: «просто не запускай без аргумента».
6. [junior] [на скорость] make test пишет make: 'test' is up to date. и ничего не запускает. Почему?
Ответ
Make считает test файлом. Если в каталоге есть файл или каталог test и нет зависимостей, цель считается свежей. Лечится строкой .PHONY: test, которая помечает цель как действие, а не файл.
Что хотят услышать: .PHONY, идея сравнения с файлом на диске.
Красный флаг: «удалю Makefile и напишу скрипт» или «touch файла».
7. [junior] [на скорость] Makefile:5: *** missing separator. Stop. Что это и как найти причину?
Ответ
В строке команды вместо TAB пробелы (или в строке вообще не команда и не правило). Смотрю cat -A Makefile: TAB виден как ^I, пробелы остаются пробелами. Исправляю отступ на TAB и настраиваю редактор не заменять TAB пробелами для Makefile.
Что хотят услышать: TAB, cat -A, настройка редактора.
Красный флаг: не знает про TAB и переписывает файл целиком.
8. [middle] Деплой-скрипт иногда «работает», а иногда после curl | tar xz оставляет пустой каталог. Как найти и исправить?
Ответ
Без режима set -o pipefail код выхода конвейера равен коду последней команды: упавший curl теряется, а tar получит пустой или обрезанный поток, и скрипт пойдёт дальше с пустым или частично распакованным результатом. Включаю set -euo pipefail (-e остановка при первой ошибке, -u ошибка на незаданной переменной, pipefail ошибка конвейера при сбое любой его части), скачиваю в файл, проверяю код и контрольную сумму (хэш файла SHA256, по нему видно, что файл не повреждён), потом распаковываю. Подробнее об этом в уроке 1.7.
Что хотят услышать: pipefail, скачивание отдельно от распаковки, проверка SHA256.
Красный флаг: «добавлю sleep и повторю» или «|| true».
9. [middle] Скрипт с for f in $(ls *.log) ломается на некоторых файлах. Почему и как переписать?
Ответ
$(ls ...) даёт текст, который разбивается по пробелам: файл my app.log превращается в два слова. Правильно: for f in *.log; do ... "${f}" ...; done, а если файлов может не быть, проверять [ -e "${f}" ] || continue (иначе шаблон остаётся буквальной строкой *.log). Для рекурсивного обхода каталогов find ... -print0 | xargs -0 или find -exec: -print0 разделяет имена нулевым байтом, а не пробелом.
Что хотят услышать: разбиение на слова, раскрытие шаблонов оболочкой, -print0, кавычки.
Красный флаг: «парсить вывод ls нормально».
10. [middle] В Makefile команда cd app на одной строке, а python3 app.py на следующей запускается не в том каталоге. Почему?
Ответ
Каждая строка рецепта выполняется в отдельной оболочке, поэтому cd не переносится. Решения: одна строка cd app && python3 app.py, обратные слэши для многострочной команды или директива .ONESHELL (весь рецепт в одной оболочке). Переменные оболочки между строками тоже не сохраняются, а $ для оболочки пишется как $$.
Что хотят услышать: отдельный shell на строку, &&, $$, .ONESHELL.
Красный флаг: «баг Make».
11. [middle] Нужно проверить, что сервис «поднялся» после деплоя, в скрипте выкладки. Как сделаешь и какие ловушки учтёшь?
Ответ
Цикл с ограничением попыток и таймаутом каждой: curl -fsS --max-time 2 /readyz, пауза между попытками, итоговый ненулевой код при неудаче, сообщение в stderr. Проверяю готовность (/readyz: «могу принимать работу»), а не просто «порт открыт» или живость (/healthz: «процесс работает»): процесс может слушать порт, но не уметь писать в хранилище. Пауза без потолка превращается в вечное зависание пайплайна (автоматической цепочки выкладки).
Что хотят услышать: ограниченный цикл, --max-time, -f, различие живости и готовности, код выхода.
Красный флаг: sleep 30 и «должно хватить».
12. [junior] Чем &&, || и ; отличаются при соединении команд?
Ответ
a ; b запускает b всегда. a && b запускает b только если a завершилась с кодом 0. a || b запускает b только если a завершилась с ошибкой. Например, mkdir out && cd out не зайдёт в каталог, который не удалось создать, а test -f x || echo нет файла выведет сообщение только при отсутствии файла. Результат всей цепочки - код последней выполненной команды. Конструкцию a && b || c использую осторожно: c сработает и при сбое b.
Что хотят услышать: ; всегда, && при успехе, || при ошибке, связь с кодами возврата, ловушка a && b || c.
Красный флаг: «&& и ; - это одно и то же».
13. [middle] Чем "$@" отличается от $* и зачем кавычки?
Ответ
$@ и $* содержат все аргументы скрипта. В кавычках "$@" каждый аргумент остаётся отдельным словом, даже если в нём пробелы, а "$*" склеивает всё в одну строку. Без кавычек оба варианта режутся по пробелам и подвержены подстановке масок. Когда передаю аргументы дальше, пишу команда "$@": так ./run.sh "my file" не развалится на два аргумента. Ещё пригодятся $# (число аргументов) и shift.
Что хотят услышать: "$@" сохраняет границы аргументов, "$*" склеивает, словоразбиение без кавычек, $# и shift.
Красный флаг: «Разницы нет, кавычки ставлю для красоты».
Проверено на версиях
Прогонялось в Docker-контейнерах на этом уроке (2026-09-30):
- Ubuntu 24.04.5 LTS (образ
devops-lab:24.04, aarch64): bash 5.2.21, GNU Make 4.3, curl 8.5.0, Python 3.12.3. Прогнаны все команды заданий 1-5 и три сценария поломок (break.sh,shellcheckбез замечаний) с возвратомfix. Вывод в уроке взят из этого прогона. - Ubuntu 26.04 (образ
ubuntu:26.04, без systemd): bash 5.3.9, GNU Make 4.4.1, curl 8.18.0, Python 3.14.4. Прогнаныcheck.sh, ошибки оболочки,make test,make lintи сценарий 1. Отличие только в тексте ошибки curl (Could not connect to server). Тесты проходят на обеих версиях Python (на 3.14 безwith errвcallпечаталисьResourceWarning). app.py: v2.2 изproject/notes/versions/v2.2.py, проверен на обеих системах.- Не прогонялось: запуск под пользователем
notesи через systemd (это урок 1.8);make runпроверен запуском и остановкойCtrl+C, без интерактивного терминала.
Итог урока: ты умеешь
- умею задавать переменные, значения по умолчанию и правильно брать подстановки в кавычки
- умею объяснить, чем обычная переменная отличается от экспортированной и кто её видит
- умею проверять код выхода команды и завершать скрипт своим кодом
- умею писать
if,forиwhileдля проверок и ожидания сервиса - умею сделать
curl-проверку, которая падает на 4xx и 5xx и не зависает - умею читать
app.pyчерезgrep: переменные, маршруты, файл данных - умею писать Makefile с
.PHONYи находить ошибкуmissing separator - умею запускать
make testдля «Заметок» и объяснять, почему тест использует свободный порт
Дальше: Урок 1.7: Bash в эксплуатации: надёжные скрипты, cron и логи
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.