✻ Урок 1.4 · Тема 1: Linux, Bash и systemd
Процессы и сигналы
Содержание урока
Зачем это нужно
На дежурстве часто звучит одна и та же фраза: «сервис завис, kill не помогает». Почти всегда причина в том, что человек не знает, что именно он посылает процессу. Сейчас мы это исправим.
Сервис завис, порт занят, kill не помогает, в списке процессов торчит <defunct>, load average 8 на четырёх ядрах. Расшифруем по порядку. Процесс (process) это запущенная программа: файл на диске просто лежит, а процесс уже работает. Порт (port) это номер «окошка» на машине: программа занимает окошко с номером, например 8080, и принимает через него обращения по сети; два процесса не могут занять одно окошко, отсюда «порт занят». kill это команда, которая посылает процессу сигнал (signal): короткую «записку» от ядра вроде «закругляйся». <defunct> помечает завершившийся процесс-«зомби», о котором поговорим ниже. Load average это среднее число процессов, которые хотят процессор или ждут диска: если оно больше числа ядер (ядро процессора это один «исполнитель», 4 ядра делают 4 дела одновременно), машина перегружена, и всё тормозит. Любая авария на Linux-сервере рано или поздно сводится к вопросам: что сейчас запущено, кто держит порт, как остановить программу и почему она не останавливается. Без этих знаний ты либо перезагружаешь сервер «на удачу», либо ждёшь, пока кто-то опытнее разберётся.
Позже эта тема вернётся. Docker (инструмент, который запускает программу в изолированной «коробке», контейнере; тема 4) при команде docker stop шлёт программе сигнал SIGTERM («заверши работу аккуратно»), ждёт и только потом шлёт SIGKILL («умри немедленно», программа не успевает ничего сохранить). Kubernetes (система, которая запускает много контейнеров на многих серверах; тема 5) делает то же самое и ждёт столько секунд, сколько указано в настройке terminationGracePeriodSeconds (от grace period, «льготный срок»). SIGINT это сигнал «прервать», его шлёт терминал, когда ты нажимаешь Ctrl+C. Код выхода (exit code) это число, которое процесс оставляет после завершения: 0 значит «всё хорошо», другое число значит «ошибка» или «убит сигналом». Код 137 в описании упавшего контейнера ты расшифруешь сам. Все эти слова разберём в этом уроке.
Шаг проекта: «Заметки» v2 превращаются в v2.1. Программа app.py научится ловить SIGTERM и SIGINT, дорабатывать открытые запросы и выходить с кодом 0, а не обрываться посреди записи.
Что нужно знать
- Урок 1.1: терминал и файловая система: команды
manи--help(справка),/procкак обычный каталог (каталог, через который ядро показывает данные о процессах; подробно про файловую систему в уроке 1.5), запускpython3 app.py, редакторnano. - Урок 1.2: потоки и конвейеры:
2>&1,|,grep,awk. Ими мы будем разбирать выводpsиss. - Урок 1.3: пользователи и права: владелец процесса,
sudo, почему чужой процесс безsudoне остановить.
Картина целиком
Представь большой офис. Каждый сотрудник - это процесс: у него есть табельный номер, начальник, кто его нанял, и рабочее место. Все сотрудники сидят в одном здании (ядро Linux), а на вахте работает ядро (главная программа системы, которая раздаёт процессор и память, подробно в уроке 1.5): оно решает, кому сейчас дать процессор, и передаёт сотрудникам записки. Записка - это сигнал: «закругляйся» (SIGTERM), «немедленно на выход» (SIGKILL), «подожди» (SIGSTOP). Когда сотрудник уходит, он оставляет начальнику отчёт о причине ухода (код выхода). Пока начальник отчёт не прочитал, запись о сотруднике висит в журнале: это зомби (процесс, который уже не работает и не занимает память, но ещё числится в списке, потому что родитель не забрал его код выхода).
Аналогия неточна в одном: сотрудников в офисе нанимает отдел кадров, а процессы создаёт сам процесс-родитель, копируя себя. Поэтому у процессов есть дерево родства с одним корнем. Поток (thread) это ещё один «рабочий» внутри одного процесса: как два повара на одной кухне с общими продуктами. Процесс один, а дел он делает сразу несколько.
flowchart TD
K["Ядро Linux<br>планировщик, сигналы, таблица процессов"] --> I["PID 1: /sbin/init (systemd)<br>корень дерева, усыновляет сирот"]
I --> S["sshd (PID 811)<br>принимает вход по ssh"]
S --> S2["sshd (PID 1040)<br>твой сеанс"]
S2 --> B["bash (PID 1053)<br>твоя оболочка, родитель всего, что запустишь"]
B --> A["python3 app.py (PID 1081)<br>«Заметки», порт 8080"]
B --> P["ps -ef (PID 1090)<br>живёт долю секунды"]
A -.->|"kill -TERM 1081: ядро кладёт «записку» в процесс"| X["процесс обрабатывает сигнал сам (v2.1) или умирает (v2)"]
X -.->|"код выхода уходит родителю: в $? виден 143 или 0"| B
Дальше по порядку: что такое процесс и как его разглядеть (задание 1), какие бывают состояния и сигналы (задание 2), почему остаются зомби и как считается нагрузка (задание 3), кто занимает порт (задание 4) и как правильно остановить «Заметки» (задание 5).
Теория
Программа и процесс
Файл на диске, например /usr/bin/python3, сам ничего не делает: это просто набор байтов. Чтобы что-то произошло, ядро должно загрузить программу в память, выделить ей время процессора и вести учёт: сколько памяти она заняла, какие файлы открыла, от чьего имени работает. Единица такого учёта и есть процесс (process). Без неё нельзя было бы запустить две копии одной программы, ограничить их по правам или остановить одну из них.
Программа - это рецепт в книге. Процесс - это повар, который сейчас по нему готовит, со своей кухней, своими продуктами и своим номерком. По одному рецепту могут готовить сразу трое, и они друг другу не мешают. Аналогия перестаёт работать в одном месте: повар может сам «раздвоиться» и одновременно готовить второе блюдо. Процесс так умеет: создание дочернего процесса называют fork.
У каждого процесса ядро хранит короткий паспорт:
- PID (process id) - уникальный номер процесса, пока он жив. Номера выдаются по возрастанию, начиная с 1. Когда процесс умер, его номер со временем может достаться новому.
- PPID (parent process id) - номер родителя, то есть процесса, который создал этот. Родитель есть у всех, кроме самого первого (PID 1, о нём в теме про systemd, урок 1.8).
- UID - номер владельца (пользователя, из урока 1.3). От него зависит, к каким файлам процесс получит доступ.
- Открытые файловые дескрипторы (file descriptor, fd) - пронумерованные «трубки», через которые процесс читает и пишет. Дескриптор 0 это stdin (ввод), 1 это stdout (вывод), 2 это stderr (сообщения об ошибках), то же самое, что ты видел в уроке 1.2. Дальше номера 3, 4 и так далее получают файлы и сетевые соединения, которые процесс открыл сам.
- Текущий каталог, полный путь запущенной программы и командная строка, с которой она стартовала.
Всё это ядро показывает как обычные файлы в каталоге /proc/<PID>/ (каталог /proc из урока 1.1 не хранится на диске, ядро выдумывает его содержимое в момент чтения). Внутри:
| Путь | Что там |
|---|---|
/proc/PID/cmdline |
командная строка, аргументы разделены нулевым байтом, а не пробелом |
/proc/PID/status |
имя, состояние, PID, PPID, число потоков, сигналы |
/proc/PID/fd/ |
по ссылке на каждый открытый дескриптор |
/proc/PID/cwd |
ссылка на текущий каталог процесса |
/proc/PID/exe |
ссылка на запущенный файл программы |
Команды ps, top, pgrep, lsof ничего особенного не умеют: они читают /proc и красиво оформляют результат.
Мы запустим «Заметки» и получим, например, PID 179 (у тебя число будет другим). В /proc/179/status строка PPid: 178 говорит, что родитель это процесс 178, то есть твоя оболочка bash. Ссылка /proc/179/exe ведёт в /usr/bin/python3.12: это настоящий файл, который исполняется, хотя в командной строке написано просто python3. Дескриптор 2 (stderr) смотрит в /home/ubuntu/notes/app.log, потому что мы перенаправим туда ошибки. Дескриптор 3 - сетевой сокет: так процесс держит открытый порт.
Одна деталь понадобится позже. Если файл удалить, пока процесс держит его открытым, в каталоге он исчезнет, но место на диске не освободится, а в ls -l /proc/PID/fd рядом появится пометка (deleted). Диск заполнен, а du показывает мало: типичная загадка (урок 1.5).
Прикинь сам: ты запустил
python3 app.pyтри раза в разных терминалах (на разных портах). Сколько будет процессов и сколько разных PID?
Три процесса и три разных PID, хотя программа одна. У них одинаковы файл /usr/bin/python3 и, скорее всего, владелец.
Осторожно: Процесс не равен программе: одну программу можно запустить десять раз, получится десять процессов с разными PID. И процесс не равен потоку. Поток (thread) - это «второй повар» внутри одного и того же процесса, он работает с тем же паспортом и общей памятью. У «Заметок» на ThreadingHTTPServer один процесс и обычно один поток, а на каждый запрос создаётся ещё один поток на время ответа.
Главное: программа это файл, процесс это запущенная программа с паспортом из PID, PPID, UID и открытых дескрипторов, а ядро показывает его в
/proc/PID/.
Проверь понимание: ты запускаешь
python3 app.pyв двух терминалах (на разных портах). Сколько у них будет разных PID и у чего будет одинаковое?
Ответ
Два процесса и два разных PID. Одинаковые у них программа (/usr/bin/python3.12), командная строка (если ты не менял аргументы) и, скорее всего, владелец. Разные PID, PPID (у каждого терминала своя оболочка), открытые сокеты, память и текущее состояние.
Паспорт у процесса есть. Откуда берётся сам процесс и кто его родитель?
Откуда берутся процессы: fork, exec и PID 1
Кто-то должен запустить самый первый процесс, и кто-то должен запускать все остальные. В Linux сделали простую схему: новый процесс всегда создаёт уже существующий, копируя себя.
Ксерокс с последующей заменой содержимого. Ты делаешь копию бланка (это fork), а затем в копии стираешь всё и пишешь другой текст (это exec). Аналогия хромает: у копии остаются те же открытые «трубки» (дескрипторы), что и у оригинала.
Когда ты пишешь в bash ls -l, происходит четыре шага:
- bash делает fork: ядро создаёт копию bash с новым PID. PPID у копии равен PID оригинала.
- Копия вызывает exec и заменяет себя программой
ls. PID остаётся тем же, но внутри уже другая программа. - Оригинал (bash) ждёт окончания копии системным вызовом wait. Системный вызов (system call) - это просьба программы к ядру что-то сделать за неё, например создать процесс или открыть файл.
lsзавершается, ядро передаёт bash число, код выхода (exit code), и bash кладёт его в$?.
sequenceDiagram
participant B as bash (PID 1053)
participant C as копия bash (PID 1090)
participant K as ядро
B->>K: fork
K-->>C: новая копия с новым PID, PPID 1053
C->>K: exec ls
Note over C: тот же PID, внутри уже ls
B->>K: wait (ждёт копию)
C-->>K: ls завершился
K-->>B: код выхода, bash кладёт его в $?
Так растёт дерево. Корень дерева, PID 1, ядро запускает само при загрузке. Обычно это systemd (менеджер сервисов, урок 1.8). У PID 1 есть особая работа: если родитель умер раньше потомков, этих «сирот» усыновляет PID 1 и вызывает для них wait.
В нашем дереве из «Картины целиком» python3 app.py имеет PID 1081, а PPID равен PID bash (1053). Если закрыть bash «неаккуратно», процесс 1081 не исчезнет: его PPID станет 1, его усыновил PID 1. В контейнерах (тема 4) PID 1 - это уже твоя программа, и она сама должна уметь эту работу: разберём в конце теории.
Прикинь сам: ты ввёл в bash
ls -l. Сколько раз за это время появится новый PID и что будет с ним послеexec?
Один раз: fork создаёт копию bash с новым PID, а exec заменяет в ней программу на ls. PID при этом не меняется.
Осторожно: Фразу «запустить программу» представляют как одно действие. На деле это связка fork плюс exec. Из-за неё у процесса всегда есть родитель, а значит, «Заметки», запущенные из bash, зависят от bash: закроешь терминал, и они получат сигнал (об этом ниже).
Главное: любой запуск это
forkплюсexec, поэтому у каждого процесса есть родитель, а корнем дерева служит PID 1.
Проверь понимание: у процесса PPID равен 1. Что это может значить?
Ответ
Либо его запустил напрямую менеджер сервисов (так стартуют службы), либо его настоящий родитель уже умер и процесс усыновил PID 1. Чтобы отличить, смотрят на командную строку и на то, как он запускался: службу systemd можно найти в systemctl status, а «сирота» - это обычно забытый процесс, оставшийся после закрытого терминала или аварии.
Процесс родился. Какие состояния у него бывают, пока он живёт?
Состояния процесса: R, S, D, Z, T
Процесс не всегда работает: он может ждать ответа из сети, ждать диска, быть остановленным или уже умереть. Чтобы понять, что происходит с сервисом, нужно знать, в каком он состоянии, а ps показывает его одной буквой.
Повар на кухне: готовит прямо сейчас (R), ждёт, пока закипит вода, и готов отвлечься (S), стоит у окошка выдачи и ждёт продукты, отвлекаться нельзя (D), поставлен на паузу начальником (T), ушёл, но не сдал смену (Z). Аналогия неточна тем, что «стоящий у окошка выдачи» (D) для сигналов действительно недоступен, а в жизни такого человека обычно можно окликнуть.
Состояние видно в колонке STAT команды ps и в строке State: файла /proc/PID/status:
| Код | Название | Что значит | Сигналы |
|---|---|---|---|
| R | running | выполняется на процессоре или стоит в очереди на него | действуют |
| S | sleeping | ждёт события: запрос, таймер, данные из сети | действуют |
| D | uninterruptible sleep | ждёт ответа от диска или другого устройства внутри ядра | не действуют, пока ядро не вернётся |
| T | stopped | остановлен сигналом или Ctrl+Z |
действуют (SIGCONT продолжит работу) |
| Z | zombie | уже завершился, но родитель ещё не прочитал его код выхода | процесса нет, слать нечего |
После главной буквы могут стоять дополнительные знаки: l значит многопоточный, + значит процесс работает в «переднем плане» терминала, N значит пониженный приоритет (об этом дальше), s значит лидер сеанса.
Вот настоящая строка ps для «Заметок»: 179 178 ubuntu S 00:02 python3 app.py. Буква S: процесс спит и ждёт, когда придёт запрос по порту 8080. Никаких дополнительных знаков нет: поток один (l нет), а запущен в фоне (+ нет). Через секунду после запроса процесс на мгновение становится R, а потом снова S. Больше 99% жизни сервис проводит в S, и это нормально: сервис, который постоянно в R, жжёт процессор.
Прикинь сам:
psпоказывает у процесса состояниеD, иkill -9не помогает. Почему?
Процесс сидит внутри ядра в системном вызове (обычно ждёт диск или сеть). Сигнал дойдёт только после выхода из него, поэтому проблему ищут в хранилище, а не в kill.
Осторожно: Состояние D принимают за «зависший процесс, который надо убить». Убить его нельзя, это не баг kill: процесс сидит внутри ядра, в системном вызове, и ядро не даст его прервать, пока операция не завершится (например, диск ответит). Обычно D-состояние длится миллисекунды. Если оно затянулось, проблема не в программе, а в диске, сетевой файловой системе или драйвере.
Главное:
Rработает,Sспит и ждёт,Dждёт диск или сеть и не прерывается,Tостановлен,Zмёртв, но не убран.
Проверь понимание:
psпоказываетSslдля процесса. Расшифруй все три буквы.
Ответ
S: ждёт события, сигналы действуют. s: процесс лидер сеанса (первый процесс в группе, которую создал терминал или менеджер служб). l: у процесса несколько потоков.
Состояния видны в ps и top. Научимся читать их колонки без догадок.
Как читать ps и top: колонки без догадок
ps и top показывают одни и те же процессы, но в разных обликах, и у новичка теряется время на вопрос «что значит эта колонка». Разберём один раз по настоящему выводу.
Список сотрудников в разных формах: для отдела кадров (кто чей начальник и кем оформлен), для бухгалтерии (сколько ресурсов ест) и для охраны (где сейчас сидит). Данные одни, отбор колонок разный. Аналогия неточна тем, что у ps нет «правильной» формы, ты сам выбираешь колонки ключом -o.
У ps есть две устоявшиеся «раскладки». ps aux (стиль BSD, без дефиса) даёт колонки для оценки ресурсов; ps -ef (стиль UNIX, с дефисом) даёт родство: колонку PPID. Ключ -e (или a вместе с x) означает «все процессы, а не только мои в этом терминале». Ключ --forest рисует дерево родства линиями \_. Когда нужны конкретные колонки, пишут ps -o pid,ppid,stat,cmd -p PID (так мы делали в заданиях). top показывает то же в живом режиме с обновлением; ключ -b -n 1 делает один кадр для скриптов.
Настоящие строки ps aux на нашем стенде:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 20752 11468 ? Ss 11:52 0:00 /sbin/init
root 24 0.0 0.1 50148 15416 ? S<s 11:52 0:00 /usr/lib/systemd/systemd-journald
Читаем слева направо. USER владелец. PID номер. %CPU и %MEM доля процессора и оперативной памяти. VSZ (virtual size) весь адресный объём, который процесс запросил, в килобайтах: он часто огромен и почти ничего не значит. RSS (resident set size) сколько килобайт реально лежит в памяти (у первого процесса 11468 КБ, около 11 МБ): это число и смотрят. TTY терминал, знак ? значит «терминала нет», это служба. STAT состояние из прошлого раздела: у второго процесса S<s, где < значит повышенный приоритет (у systemd-journald он специально выше, чтобы не терять записи журнала). START время запуска, TIME сколько процессорного времени он потратил (не путай с временем жизни), COMMAND командная строка.
В top -b -n 1 шапка строкой %Cpu(s): 0.0 us, 0.0 sy, ... 99.4 id разбирает процессор на доли: us (user) программы пользователя, sy (system) работа ядра, wa (wait) ожидание диска, id (idle) простой. Ниже колонки PR и NI (приоритет), VIRT, RES, SHR (память), S состояние, %CPU, TIME+. Когда сервер «тормозит», сначала смотрят id и wa.
Прикинь сам: в
ps auxу процессаVSZ2000000, аRSS30000. Сколько памяти он реально занимает?
Около 30 МБ: RSS 30000 КБ это память, которая реально выделена. VSZ это всего лишь зарезервированный адресный объём.
Осторожно: Столбец %CPU в ps это доля за всё время жизни процесса, а в top это доля за последний интервал. Поэтому процесс, который только что «взбесился», ps покажет скромным числом, а top сто процентами. И ещё: процесс с %CPU 100 на многоядерной машине занимает одно ядро целиком, а не «всю машину».
Главное:
ps auxпоказывает ресурсы (%CPU,%MEM,RSS),ps -efродство (PPID), аtopобновляется и считает%CPUза последний интервал.
Проверь понимание: в
ps auxу процессаVSZ2000000, аRSS30000. Сколько памяти он реально занимает?
Ответ
Около 30 МБ (RSS 30000 КБ). VSZ это виртуальный объём: сколько адресов процесс зарезервировал, большая часть из них не выделена физически. Как эти числа связаны с нехваткой памяти, разберём в уроке 1.5.
Мы научились смотреть на процессы. Теперь научимся с ними разговаривать: сигналами.
Сигналы: короткие записки процессу
Как сказать работающей программе «остановись»? Закрыть её окно нельзя, окна нет. Нужен способ послать процессу короткое уведомление, которое ядро доставит вне зависимости от того, чем он занят. Это сигнал (signal): число, которое ядро кладёт в «почтовый ящик» процесса.
Записка, воткнутая в дверь кабинета. Сотрудник видит её при первой возможности и решает: выполнить, проигнорировать или сделать по-своему. Но есть два вида записок, которые не читаются, а исполняются силой: «стоп» и «уволен» (SIGSTOP и SIGKILL). Аналогия неточна: настоящая записка требует, чтобы человек её увидел, а ядро SIGKILL просто выполняет, не спрашивая процесс.
Отправляет сигнал команда kill -СИГНАЛ PID (название обманывает: она не «убивает», а шлёт сигнал; убивает только тот сигнал, который так задуман). Когда сигнал прилетел, у процесса три варианта: сделать действие по умолчанию (обычно умереть), поймать сигнал своим обработчиком (handler, кусок кода на этот случай) или проигнорировать. Пункт «поймать» появляется только у программ, которые для этого написаны. Выбор сигналов:
| Сигнал | Номер | Можно поймать | Смысл |
|---|---|---|---|
| SIGHUP | 1 | да | «терминал закрыт» (hangup, повесили трубку); у служб принято понимать как «перечитай конфиг» |
| SIGINT | 2 | да | то же, что Ctrl+C (interrupt, прерывание) |
| SIGKILL | 9 | нет | ядро убирает процесс само, программа ничего не узнает |
| SIGTERM | 15 | да | «пожалуйста, завершайся» (terminate); kill PID без ключа шлёт именно его |
| SIGSTOP | 19 | нет | заморозить процесс (в ps станет T) |
| SIGCONT | 18 | да | продолжить замороженный (continue) |
Полный список даёт kill -l. Номера удобно знать наизусть только четыре: 1, 2, 9, 15.
Есть особый «сигнал 0»: kill -0 PID ничего процессу не посылает, но проверяет, что такой процесс существует и ты можешь ему писать. Так проверяют «жив ли процесс» в скриптах.
Разобранный пример: почему порядок TERM, пауза, KILL. Допустим, «Заметки» в момент остановки записывают новую заметку в файл. При SIGTERM программа может дописать строку и выйти чисто. При SIGKILL её выбросят из памяти посреди write: в файле может остаться половина строки, а PID-файлы, блокировки и временные файлы, которые программа собиралась убрать, останутся на диске. Порт при этом освободит ядро, но целостность данных не гарантируется. Поэтому порядок такой: сначала kill -TERM, подождать 10-30 секунд, и только если процесс не ушёл, kill -KILL, а потом выяснить, почему он не завершался сам.
Кому можно слать сигналы. Своим процессам можно, чужим нельзя: ядро сравнивает твой UID с UID владельца процесса, и при несовпадении отвечает Operation not permitted (исключение: root, поэтому sudo kill). Это защита от ситуации, когда один пользователь останавливает работу другого. Отсюда практическое правило: сервис под пользователем notes (урок 1.3) ты остановишь только через sudo, а не от своей учётной записи. И kill -0 на чужой процесс тоже даст отказ, хотя процесс жив: по коду возврата 1 нельзя сразу утверждать, что процесса нет, нужно прочитать сообщение.
Прикинь сам: ты отправил
kill -9 PIDпрограмме, которая писала в файл. Успеет ли она дописать строку?
Нет: SIGKILL нельзя поймать, процесс умирает сразу. Поэтому сначала шлют SIGTERM, ждут и только потом при необходимости SIGKILL.
Осторожно: Ошибка номер один: «SIGKILL надёжнее, всегда буду использовать его». Он действительно надёжнее и одновременно опаснее. Ошибка номер два: думать, что kill посылает сигнал именно «убить». kill -HUP и kill -STOP процесс не убивают вовсе. И ещё: без прав нельзя послать сигнал чужому процессу (Operation not permitted), нужен sudo (урок 1.3).
Главное:
killшлёт сигналы: SIGTERM (15) просит завершиться, SIGINT (2) этоCtrl+C, SIGHUP (1) закрытие терминала, SIGKILL (9) убивает без шансов.
Проверь понимание: программа не реагирует на
kill PID. Что ты сделаешь дальше и почему не сразуkill -9?
Ответ
kill PID шлёт SIGTERM. Если программа зависла, подожду и проверю, жива ли она (ps -p PID), посмотрю её состояние: возможно, она в D и сигналы ждут ядра. Если она в S или R и не отвечает, шлю kill -KILL, но только после паузы и зная, что потеряю недописанные данные. Сразу -9 нельзя: программа не сможет ни сохранить данные, ни убрать за собой.
Процесс умер или завершился сам. Как он сообщает, чем закончил?
Код выхода: как процесс сообщает, чем закончил
Родителю нужно знать, удалась ли работа: скрипт запустил программу, а дальше что? Для этого у каждого процесса есть код выхода (exit code): число от 0 до 255, которое ядро передаёт родителю при завершении. Ноль значит «успех», всё остальное «что-то пошло не так». Без него ни systemd, ни Docker, ни скрипты не узнали бы, стоит ли перезапускать сервис.
Отчёт, который уходящий сотрудник оставляет на столе начальника: «0» - сдал всё в порядке, любое другое число - причина ухода. Аналогия неточна: у процесса нет слов, только число, и его смысл договорной.
Если программа завершилась сама, число выбирает она: sys.exit(0) в Python, exit 1 в bash. Если процесс убит сигналом, кода у программы нет, потому что она не успела ничего сказать. Тогда оболочка вычисляет число сама: 128 плюс номер сигнала. Значение последней команды лежит в переменной $? (bash подставляет её значение, когда встречает знак $).
| Код | Как получился | Что значит |
|---|---|---|
| 0 | сама завершилась | успех |
| 1 | сама завершилась | общая ошибка (так Python выходит при необработанном исключении) |
| 129 | 128 + 1 | убит сигналом SIGHUP |
| 130 | 128 + 2 | прерван Ctrl+C (SIGINT) |
| 137 | 128 + 9 | убит SIGKILL (или ядром при нехватке памяти, урок 1.5) |
| 143 | 128 + 15 | получил SIGTERM и не обработал его |
В задании 2 мы запустим sleep 300 & и пошлём kill -TERM $!. Сразу после этого echo $? покажет 0: это код самой команды kill (она успешно отправила сигнал), а не процесса sleep. Настоящий код процесса вернёт wait $! (команда bash: «дождись этого фонового процесса и выдай его код»): там будет 143, ведь 128 плюс 15 равно 143.
Код выхода управляет и самим bash. Знак && значит «выполни следующую команду, только если предыдущая вернула 0», а || наоборот, «только если вернула не 0». Поэтому grep 8080 файл || echo "нет" печатает «нет», когда grep ничего не нашёл (он тогда возвращает 1). Так же читают коды выхода systemd и Docker: 0 значит «остановился нормально, перезапускать не надо», другое число означает сбой (урок 1.7 разбирает это в скриптах).
Прикинь сам: чему равны коды выхода процесса, убитого SIGTERM и SIGKILL?
143 и 137: к 128 добавляют номер сигнала, то есть 128 + 15 и 128 + 9.
Осторожно: Читают $? не там, где надо (как в примере выше), и думают, что процесс завершился успешно. Ещё путают 137 и 143. 143 значит «попросили завершиться, а программа не умеет», 137 значит «убили насильно» (либо ждать не стали, либо программа не отреагировала, либо ядро убило её за память).
Главное: код выхода 0 значит успех, другое число ошибку, а 128 плюс номер сигнала значит, что процесс убит сигналом.
Проверь понимание: контейнер завершился с кодом 137. Что произошло?
Ответ
137 = 128 + 9: процесс убит сигналом SIGKILL. Либо ему послали SIGTERM, он не завершился за отпущенное время, и его убили насильно (так делает docker stop, тема 4), либо его убило ядро из-за нехватки памяти (OOM killer, урок 1.5). Код 143 означал бы, что процесс получил SIGTERM и не обработал его.
До сих пор процесс занимал наше окно. Как запускать его в фоне и что будет при закрытии терминала?
Фон, задания и закрытие терминала
Когда ты запускаешь python3 app.py в терминале, он «занимает» окно: пока сервис работает, командную строку не вернуть. Нужен способ отправить программу «жить отдельно» и продолжать работать. И нужно понимать, что произойдёт с ней, когда ты закроешь терминал.
Забег и эстафета. Обычный запуск - ты бежишь и держишь палочку, пока не финишируешь. Фон - ты отдал палочку помощнику и вернулся к делам. Но помощник остаётся в твоей команде (его родитель - твоя оболочка), и когда команда расходится по домам (закрыт терминал), он расходится тоже.
Знак & в конце команды запускает её в фоне (background): bash не ждёт её завершения и сразу возвращает приглашение. Каждому фоновому запуску bash даёт номер задания (job) вида [1] и печатает PID. Полезные вещи:
$!- переменная с PID последнего запущенного в фоне процесса;jobs- список заданий этой оболочки;Ctrl+Z- остановить текущее задание (терминал посылает ему сигнал SIGTSTP, похожий на SIGSTOP, состояние T);bg- продолжить остановленное задание в фоне,fg- вернуть в передний план;wait PID- дождаться конца фонового процесса и получить его код выхода.
Когда закрывается терминал, ядро посылает оболочке SIGHUP, а она передаёт его своим заданиям. Обработчика нет, поэтому они умирают (код 129). Команда nohup КОМАНДА & запускает программу так, что SIGHUP она игнорирует.
Команда NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log & разбирается слева направо. NOTES_DATA=/tmp/notes-demo.txt перед командой задаёт переменную окружения только для этого запуска (урок 1.1). python3 app.py это сама программа. 2>> app.log направляет поток ошибок (2) в файл app.log, причём >> дописывает в конец, а не затирает. & уходит в фон. Ответ bash выглядит так: [1] 179, где [1] номер задания, 179 PID.
Вывод jobs читается так: [1]+ Running NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log &. В скобках номер задания, знак + значит «текущее задание» (к нему относятся fg и bg без номера; предыдущее помечается -), затем состояние (Running работает, Stopped остановлено Ctrl+Z, Done завершилось успешно, Exit 1 завершилось с кодом 1, Terminated убито SIGTERM) и сама команда. Номер задания с процентом (kill %1) работает только в этой оболочке, а PID работает везде.
Прикинь сам: ты запустил
python3 app.py &, закрыл терминал, и сервис пропал. Почему и выживет ли он сnohup?
Закрытие терминала посылает SIGHUP оболочке, она передаёт его заданиям, и сервис без обработчика умирает. С nohup он закрытие переживёт, но не переживёт перезагрузку: для этого нужен менеджер служб.
Осторожно: nohup и & принимают за способ запустить сервис «навсегда». Они переживут закрытие терминала, но не переживут перезагрузку сервера и не запустятся заново после падения. Для постоянных сервисов нужен менеджер сервисов, урок 1.8. SIGHUP у служб имеет второй смысл, который ты встретишь в теме про nginx и Prometheus: они не умирают по нему, а перечитывают конфигурацию без остановки.
Главное:
&запускает в фоне,$!хранит PID последнего фонового процесса, аnohupзащищает только от закрытия терминала.
Проверь понимание: ты запустил сервис
python3 app.py &, закрыл терминал, и сервис пропал. Почему и как сделать, чтобы он пережил закрытие?
Ответ
Закрытие терминала посылает SIGHUP оболочке, она передаёт его заданиям, у сервиса нет обработчика SIGHUP, и он умирает. Разово можно запустить через nohup. Для постоянной работы (перезапуск после падения и после перезагрузки) нужен systemd, урок 1.8.
Кто получает Ctrl+C и почему его получают не все процессы? Всё дело в группах.
Терминал, группа процессов и кому достаётся Ctrl+C
Когда ты нажимаешь Ctrl+C, сигнал получает не «то, что на экране» и не только одна программа. Чтобы остановить целую цепочку, например python3 app.py | tee log, ядру нужно понятие «эти процессы работают вместе». Оно называется группой процессов (process group). А чтобы знать, кому закрывать терминал, есть сеанс (session).
Рабочая бригада и смена. Бригада (группа) это все, кто занят одним заданием: бригадиру достаточно крикнуть «стоп», чтобы услышали все. Смена (сеанс) это все бригады, пришедшие с одного входа, то есть из одного терминала. Когда вход закрывают, смену отпускают целиком. Аналогия неточна: бригаду создают команды оболочки автоматически, а не ты.
- Терминал (окно с командной строкой) привязан к сеансу. Первый процесс сеанса, обычно твоя bash, называется лидером сеанса (в
STATу негоs). - Каждую команду или конвейер bash помещает в отдельную группу. Группа, которая сейчас «на переднем плане», получает ввод с клавиатуры. В
STATу таких процессов знак+. - Нажатие
Ctrl+Cтерминал переводит в SIGINT и посылает его всей группе переднего плана,Ctrl+Zв SIGTSTP. Фоновые задания не получают эти сигналы. - Закрытие терминала посылает SIGHUP лидеру сеанса (bash), а он передаёт его всем своим заданиям, и переднего, и заднего плана.
терминал (окно) <--> сеанс
|
bash (лидер сеанса, STAT: Ss)
|-- группа A (передний план, STAT: R+) <- Ctrl+C приходит сюда
| python3 app.py | tee log
\-- группа B (фон, STAT: S) <- Ctrl+C её не касается
sleep 300 & закрыли терминал: SIGHUP придёт и сюда
В задании 2 мы запустим sleep 300 без & и нажмём Ctrl+C: sleep в группе переднего плана, получает SIGINT и умирает с кодом 130. А если то же самое запустить с &, Ctrl+C ему не страшен, зато закрытие окна терминала убьёт его по SIGHUP (код 129).
Прикинь сам: в терминале работает
sleep 300 &, а ты нажалCtrl+Cв пустом приглашении. Остановится лиsleep?
Нет: Ctrl+C посылает SIGINT только группе переднего плана, а sleep в фоновой группе. Остановить его можно kill %1 или kill $!.
Осторожно: Что Ctrl+C «убивает» программу. Он отправляет SIGINT, а решает программа: Python по умолчанию превратит его в исключение KeyboardInterrupt, а другая программа может проигнорировать. Ещё путают Ctrl+C и Ctrl+D: второе не сигнал, а «конец ввода».
Главное: терминал посылает
Ctrl+CиCtrl+Zтолько группе переднего плана, а SIGHUP при закрытии получают все задания сеанса.
Проверь понимание: ты запустил сервис
python3 app.py &и нажалCtrl+C. Он остановится?
Ответ
Нет. Он в фоновой группе, Ctrl+C посылает SIGINT только группе переднего плана, то есть самой оболочке (а она свой SIGINT обрабатывает и продолжает работу). Остановить сервис можно командой kill %1 или kill PID, либо fg и потом Ctrl+C.
Теперь о самом частом вопросе дежурного: кто занял порт и как найти нужный процесс?
Как найти процесс и порт
На сервере работают сотни процессов. Чтобы остановить нужный, нужно его найти, и чаще всего ищут по вопросу «кто занял этот порт».
Порт - это номер окошка в многоэтажном здании (адрес это здание, порт это окошко). В одном окошке может сидеть только один клерк. Если окошко 8080 занято, второй клерк с тем же номером не сядет. Процесс, который «слушает» порт, это клерк, который сидит в окошке и ждёт посетителей.
Порт это число от 1 до 65535, по которому сетевая программа принимает соединения на адресе. Чтобы занять порт, программа делает системный вызов bind (привязаться к адресу и порту) и listen (начать слушать). Ядро разрешает bind на занятый порт только один раз, второй получит ошибку EADDRINUSE («address already in use»). Подробно про порты и TCP в уроке 2.2; сейчас достаточно этого.
Инструменты для поиска:
pgrep -af ШАБЛОНищет процессы по имени или командной строке. Ключ-fзначит «искать во всей командной строке, а не только в имени программы»,-aзначит «показывать полную командную строку»,-lпоказывает только имя.pkill -f ШАБЛОНшлёт сигнал (по умолчанию SIGTERM) всем найденным. Осторожно: шаблон может совпасть с чужими процессами.ss -tlnpпоказывает слушающие сетевые сокеты. Ключи:t- TCP,l- только слушающие (listening),n- порты числами,p- какой процесс. Программаssиз пакета iproute2.lsof -i :8080(list open files, «список открытых файлов»: в Linux и сокет это файл) показывает, кто открыл порт 8080. Ключ-tвыводит только PID, удобно для скриптов.
Чужие процессы без sudo не видны: колонка с процессом будет пустой, потому что ядро не раскрывает их обычному пользователю.
Вывод ss -tlnp | grep 8080 на нашем стенде:
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=179,fd=3)). Читаем: состояние LISTEN, очередь ожидающих соединений пять (это число заложено в стандартном http.server), адрес и порт 127.0.0.1:8080, то есть сервис слушает только собственный компьютер (не соседние машины), и в конце процесс: python3, PID 179, дескриптор 3 (тот самый сокет).
Прикинь сам:
ss -tlnp | grep 8080показалusers:(("python3",pid=179,fd=3)). Какой командой остановишь сервис мягко?
kill 179: она шлёт SIGTERM. Если процесс не завершится за разумное время, проверяют состояние и только потом шлют kill -9.
Осторожно: pgrep app.py без -f ничего не найдёт: по умолчанию pgrep смотрит только на имя программы, а имя здесь python3. Так что pgrep -l python3 найдёт, а pgrep app.py нет. Второй момент: при pgrep -f слишком общий шаблон совпадёт с самим pgrep или с оболочкой, где ты его написал (мы увидим это в задании 4).
Главное: порт занят значит, что его слушает процесс; найти его можно через
ss -tlnp,lsof -i :ПОРТилиpgrep -af.
Проверь понимание:
ss -tlnpпоказывает127.0.0.1:8080, но колонкаusers:пуста. Почему?
Ответ
Процесс принадлежит другому пользователю (например, notes, урок 1.3), а ss запущен без sudo. Ядро знает всё, но чужие процессы обычному пользователю не раскрывает. Повтори sudo ss -tlnp.
Процесс нашли. Теперь о том, как он делит процессор с другими и что показывает load average.
Приоритет и load average: кому достаётся процессор
Ядер меньше, чем процессов. Планировщик ядра (scheduler) решает, кто и на сколько миллисекунд получит ядро процессора. Иногда нужно попросить, чтобы тяжёлая фоновая задача уступала важным. А администратору нужно одно число, по которому видно, что машина «задыхается».
Очередь в кассу. Ядро процессора - это касса. Приоритет - это то, насколько вежливо ты пропускаешь других вперёд. Load average - это средняя длина очереди за последнюю минуту, пять и пятнадцать минут. Аналогия неточна: в очередь на диск тоже становятся, и load average считает её вместе с очередью на процессор.
Значение nice - число от -20 до 19: чем оно выше, тем «вежливее» процесс и тем меньше процессорного времени получает, когда есть конкуренты. Обычный процесс имеет 0. Простой пользователь может только повышать (делать вежливее), понижать ниже нуля может только root. nice -n 10 КОМАНДА запускает с nice 10, renice -n 15 -p PID меняет у уже работающего.
Load average (среднее число процессов, готовых работать или ждущих диска) ядро считает в трёх окнах: за 1, 5 и 15 минут. Его печатают uptime, top и файл /proc/loadavg. Само по себе число ничего не значит, его надо делить на число ядер (nproc): load 4 на четырёх ядрах означает «все заняты, очереди нет», load 8 на четырёх значит «желающих вдвое больше, чем ядер».
Мы запустим два вечных процесса yes, оба привяжем к одному ядру (taskset -c 0), один с nice 0, другой с nice 10. Они делят одно ядро в пропорции, которую вычисляет планировщик: у первого будет около 91%, у второго около 9%. А uptime покажет load average: 1.30, 0.55, 0.21: за последнюю минуту в среднем 1,3 процесса были готовы к работе, за пять минут 0,55, за пятнадцать 0,21. Тренд «1,30 больше, чем 0,21» означает, что нагрузка недавно выросла. Делим на число ядер (15 на нашем стенде): 1,30 / 15 = 0,09, то есть машина почти свободна.
Как читать цифры на трёх примерах. Машина с 4 ядрами, load average: 0.50, 0.40, 0.30: очередь почти пустая (0,5 на 4 ядра, это 12% загрузки), всё спокойно. Та же машина, load average: 4.00: каждое ядро занято, но никто не ждёт, это «под завязку», а не авария. load average: 12.00, 6.00, 2.00: очередь втрое длиннее числа ядер, и растёт (первое число, за минуту, больше третьего, за пятнадцать минут), значит, перегрузка началась недавно и усиливается. Обратная картина, 2.00, 6.00, 12.00, значит, худшее позади. Поэтому смотри не на одно число, а на три и на их порядок.
Почему именно 1, 5 и 15 минут? Одна минута быстро реагирует и показывает «что творится сейчас», но может дёрнуться от случайного всплеска. Пятнадцать минут сглажены и показывают «что было в последнее время». Сравнение коротких окон с длинным отвечает на вопрос, который чаще всего задают на дежурстве: «становится хуже или лучше». Если после перезапуска сервиса первое число упало, а третье ещё высокое, ты видишь последствия недавней проблемы, а не саму проблему.
Прикинь сам: на машине 4 ядра,
load average: 12.00, 6.00, 2.00. Что это значит?
В среднем за минуту 12 процессов хотят работать при 4 ядрах: очередь втрое длиннее. А число за минуту больше числа за 15 минут, значит, нагрузка растёт.
Осторожно: «Load average 8 значит 800% нагрузки»: нет, это очередь, а не проценты. Второе: высокий load не обязательно про процессор. Процессы в состоянии D (ждут диск) тоже считаются, так что load может быть высоким при пустом процессоре. Отличить можно по top: если в строке %Cpu(s) почти всё id (idle, «свободно»), а load большой, проблема в диске или сетевой файловой системе, а не в вычислениях.
Главное: load average это длина очереди за 1, 5 и 15 минут; его делят на число ядер, и процессы в состоянии D тоже в нём.
Проверь понимание: load average 8.0 на машине с двумя ядрами,
topпоказывает99 id. Что делать в первую очередь?
Ответ
Смотреть, нет ли процессов в состоянии D (ps -eo pid,stat,cmd | awk '$2 ~ /^D/'): очередь есть, а процессор простаивает, значит, процессы ждут диск или сеть. Искать проблему в хранилище, а не в том, «какая программа жрёт CPU».
Остался странный гость в списке процессов: зомби.
Зомби: умер, но запись осталась
После смерти процесса родитель должен узнать, как он закончил (код выхода). Пока родитель не прочитает, ядро обязано хранить этот код где-то. Это «где-то» - маленькая запись в таблице процессов. Она и называется зомби (zombie): процесс уже мёртв, память и файлы освобождены, осталась только запись «PID плюс код выхода».
Личное дело уволенного сотрудника, лежащее у начальника на столе, пока тот его не подпишет. Самого сотрудника уже нет, а дело занимает место в картотеке. Аналогия неточна: зомби не занимает ни рабочее место, ни зарплату, только строку в списке и номер PID.
- Родитель создаёт потомка (
fork). - Потомок завершается. Ядро освобождает его память, файлы и ставит в записи состояние Z.
- Ядро посылает родителю сигнал SIGCHLD («потомок изменился»).
- Если родитель вызовет
wait, он получит код выхода, и запись исчезнет. - Если родитель
waitне вызывает (ошибка в его коде), запись остаётся, пока не умрёт родитель. Тогда зомби усыновляет PID 1 и сразу убирает.
Поэтому «убить зомби» бессмысленно: он и так мёртв, kill -9 по нему проходит молча и ничего не меняет. Лечится через родителя: починить его код, послать ему SIGCHLD или остановить, чтобы зомби усыновил PID 1.
В задании 3 мы запустим маленькую программу: она делает fork, потомок сразу выходит, а родитель засыпает и wait не вызывает. В ps мы увидим 218 217 Z [python3] <defunct>: PID потомка 218, PPID (родитель) 217, состояние Z. Квадратные скобки вокруг python3 и слово <defunct> («несуществующий») ставит ps сам, потому что у мёртвого процесса нет командной строки. После kill родителя зомби исчезнет при следующем запуске ps.
Прикинь сам:
psпоказывает три зомби с одним PPID 2200. Кого надо остановить?
Не зомби: они уже мертвы. Смотри на родителя 2200 (ps -o pid,stat,cmd -p 2200): он не вызывает wait. Его чинят или перезапускают, и PID 1 заберёт зомби.
Осторожно: Зомби не ест CPU и не занимает память, из-за них не «тормозит система». Единственная реальная проблема: запись держит PID, а число PID ограничено, и при тысячах зомби новые процессы не смогут стартовать. Один-два зомби, пока родитель работает, чаще всего просто признак того, что родитель ещё не успел вызвать wait.
Главное: зомби это мёртвый процесс, у которого родитель не прочитал код выхода; убить его нельзя, лечится только через родителя.
Проверь понимание:
psпоказывает 3 зомби с одним и тем же PPID 2200. Что делать?
Ответ
Смотреть на родителя: ps -o pid,stat,cmd -p 2200. Убивать зомби бессмысленно. Нужно чинить или перезапускать родителя (сначала SIGTERM). После его смерти зомби усыновит PID 1 и уберёт. Если родитель это важный сервис, завести задачу разработчикам: он не вызывает wait для своих потомков.
Теперь соберём всё это в главное умение: остановить сервис так, чтобы никто ничего не заметил.
Корректная остановка: graceful shutdown
Сервис должен уметь остановиться так, чтобы клиент не заметил. Если убить процесс в момент обработки запроса, клиент получает обрыв соединения, а данные, которые сервис писал, оказываются недописанными. Хорошее поведение называется graceful shutdown (аккуратное завершение): поймать сигнал, перестать принимать новые запросы, дообработать текущие, выйти с кодом 0.
Кафе закрывается на ночь. Плохой вариант - выключить свет посреди заказа. Хороший - повесить табличку «закрыто», не пускать новых посетителей, обслужить тех, кто уже за столиками, и только потом запереть дверь. Аналогия неточна: у кафе есть закрытие по расписанию, а у сервиса остановка всегда неожиданна, поэтому нужен ещё и предельный срок ожидания.
Как это устроено (Python, app.py v2.1).
signal.signal(signal.SIGTERM, on_signal)говорит: «когда придёт SIGTERM, вызови функциюon_signal». То же для SIGINT (Ctrl+C).- Внутри обработчика нельзя сразу вызывать
server.shutdown(): эта функция ждёт, пока циклserve_forever()выйдет, а цикл как раз выполняется в том же потоке, где сейчас стоит обработчик. Получится взаимная блокировка (deadlock): каждый ждёт другого. Поэтомуshutdown()запускают в отдельном потоке. - После
serve_forever()вызываютserver_close(). Он ждёт завершения потоков-обработчиков запросов, но только если у сервера стоитdaemon_threads = False. ЗначениеTrue(по умолчанию вThreadingHTTPServer) значит «эти потоки можно бросить при выходе», и тогда открытый запрос оборвётся. - Чтобы не ждать вечно (клиент может зависнуть), заводится «сторожевой таймер» (watchdog): через 10 секунд
os._exit(1)жёстко выходит с кодом 1. - Второй сигнал во время остановки тоже завершает процесс сразу с кодом 1: значит, человек сильно торопится.
- Если всё прошло нормально,
sys.exit(0): код 0.
flowchart TD
A["SIGTERM или SIGINT"] --> B["on_signal: shutdown() в отдельном потоке,<br>перестать принимать новые запросы"]
B --> C["дообработать открытые запросы<br>server_close() ждёт потоки"]
C -->|"успели за 10 секунд"| D["sys.exit(0): код выхода 0"]
B --> W["сторожевой таймер 10 секунд"]
W -->|"не успели"| E["os._exit(1): код выхода 1"]
A -.->|"второй сигнал во время остановки"| E
Мы отправим на «Заметки» очень медленный запрос: тело {"text":"slow note"} передаётся со скоростью 5 байт в секунду (curl --limit-rate 5), то есть около четырёх секунд. Через секунду шлём SIGTERM. У v2 (без обработчика) процесс умирает сразу, curl получает Empty reply from server, код выхода 143, заметка не записана. У v2.1 сервер прекращает принимать новые соединения, дожидается конца нашего запроса, записывает заметку, отвечает HTTP 201 и выходит с кодом 0 примерно через 2 секунды после сигнала (время зависит от скорости).
Почему запись в файл не рвётся. В app.py из урока 1.3 есть блокировка LOCK: записи в файл заметок идут по одной, чтобы две заметки не перемешались. Запрос, который успел взять блокировку, дописывает строку целиком. Поэтому при остановке нужно дать ему закончить: если процесс исчезнет в середине, в файле останется обрезанная строка, а при следующем чтении заметки она сломает разбор. Обработчик сигнала и server_close() как раз и нужны, чтобы запись не оборвалась, а сервер ушёл только после неё.
Прикинь сам: зачем в обработчике SIGTERM вызывают
server.shutdown()в отдельном потоке?
Иначе обработчик, который работает в том же потоке, что serve_forever(), будет ждать выхода цикла, а цикл ждёт обработчик: получится взаимная блокировка (deadlock).
Осторожно: Думают, что «поймать SIGTERM» значит «игнорировать». Игнорировать нельзя: если сервис никогда не завершается по SIGTERM, менеджер (docker stop, Kubernetes, systemd) через положенное время пришлёт SIGKILL, и вся аккуратность будет зря. И SIGKILL поймать нельзя вообще, поэтому программа, которая умирает от kill -9 посреди записи, должна выдерживать такие смерти на уровне данных (проверки при старте, атомарные записи, об этом урок 1.7).
Главное: graceful shutdown: поймать SIGTERM, перестать принимать новые запросы, дообработать текущие и выйти с кодом 0, а на случай зависания держать сторожевой таймер.
Проверь понимание: зачем в v2.1 нужен таймер на 10 секунд, если можно просто дождаться клиентов?
Ответ
Клиент может зависнуть или вообще не закончить запрос, тогда ожидание не кончится никогда, и менеджер всё равно убьёт сервис через SIGKILL. Таймер гарантирует, что программа завершится сама в разумный срок, пусть и с кодом 1 (то есть «не всё удалось доделать»).
Тот же приём понадобится везде, где процессами управляет кто-то другой.
Где это встретится дальше
Три вещи из этого урока вернутся в следующих темах. Сейчас достаточно знать, что они существуют.
- PID 1 в контейнере. В контейнере (тема 4) твоя программа становится PID 1. Ядро не применяет к PID 1 действия по умолчанию: если у программы нет обработчика SIGTERM, сигнал просто ничего не сделает. Поэтому
docker stopждёт 10 секунд, а потом присылает SIGKILL (получается код 137). Также Dockerfile надо писать в «exec-форме» (CMD ["python3", "app.py"]), иначе PID 1 будет оболочкой, и она не передаст сигнал программе. Схема остановки одинакова везде, где есть менеджер процессов: systemd, Docker и Kubernetes. Разница в том, кто держит часы:
время -> 0 с 10 с (docker, systemd) / 30 с (Kubernetes)
| |
менеджер: SIGTERM ---------------------> SIGKILL (если процесс ещё жив)
программа: [поймала, дообработала, exit 0] или [не успела: код 137]
код выхода: 0 (v2.1) 137 (SIGKILL) / 143 (v2, не поймала TERM)
Отсюда правило, которое ты будешь применять во всех следующих темах: у сервиса должен быть обработчик SIGTERM, а время его работы должно укладываться в срок ожидания менеджера. Это ровно то, что мы сделаем в задании 5.
- Kubernetes (тема 5) при остановке пода делает то же: SIGTERM, потом ждёт
terminationGracePeriodSeconds(по умолчанию 30 секунд), потом SIGKILL. Хорошо написанный v2.1 в этой схеме останавливается за секунды. - Права процесса. Кроме владельца (UID) у процесса есть набор «привилегий» (capabilities): например, право занимать порты ниже 1024 без root. Это ещё один слой к идее «сервис под своим пользователем» из урока 1.3; в темах про Docker и Kubernetes к нему вернёмся.
Прикинь сам: что произойдёт при
docker stop, если у программы в контейнере нет обработчика SIGTERM?
Docker подождёт 10 секунд, пришлёт SIGKILL, и процесс завершится с кодом 137: у PID 1 нет действия по умолчанию для SIGTERM.
Главное: systemd, Docker и Kubernetes останавливают процесс одинаково: SIGTERM, ожидание, SIGKILL; сервис должен уложиться в срок ожидания.
Теория закончена. В практике ты найдёшь процесс «Заметок», пошлёшь сигналы и научишь сервис останавливаться аккуратно.
Практика
Среда: Ubuntu 26.04 LTS или 24.04. Нужные программы: procps (ps, top, pgrep, pkill), iproute2 (ss), lsof. Обычно они уже есть; если нет:
sudo apt update
sudo apt install -y lsof procps iproute2
Значения PID, время и номера inode в выводах ниже взяты из настоящего прогона. У тебя они будут другими, важна форма вывода.
Задание 1. Найти процесс «Заметок» и его порт
Цель: по имени найти процесс, его владельца, родителя и порт, а потом заглянуть в /proc.
Предскажи: сколько процессов покажет pgrep -af app.py при одном запущенном сервисе и какой у него PPID? Что в /proc/<PID>/cmdline окажется между словами вместо пробелов?
Ответ
Один процесс: сервер на ThreadingHTTPServer использует потоки внутри процесса, а не отдельные процессы. Его родитель это твоя оболочка. Аргументы в cmdline разделены нулевыми байтами, а не пробелами, поэтому их печатают через tr '\0' ' '.
Шаги.
- Запусти сервис в фоне. Часть команды уже разобрана выше (переменная перед командой,
2>>,&);echo "PID сервиса: $!"печатает PID последнего фонового процесса.
cd ~/notes
NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log &
echo "PID сервиса: $!"
- Найди процесс и его родителя. Разбор второй команды:
psпоказывает процессы;-o pid,ppid,user,stat,etime,cmdвыбирает колонки (номер, номер родителя, владелец, состояние, сколько времени работает, команда);-p ...ограничивает список нужным PID;$(...)подставляет вместо себя вывод вложенной команды, то естьpgrep -f 'python3 app.py'выдаёт PID, и он становится значением-p.
pgrep -af app.py
ps -o pid,ppid,user,stat,etime,cmd -p "$(pgrep -f 'python3 app.py')"
- Найди порт двумя способами. Разбор:
ss -tlnpразобран в теории (TCP, слушающие, числа, процессы);| grep 8080оставляет только строки с 8080 (конвейер из урока 1.2);lsof -i :8080показывает открытые файлы-сокеты на порту 8080.
ss -tlnp | grep 8080
lsof -i :8080
- Загляни внутрь процесса. Разбор:
PID=$(...)кладёт число в переменную;tr '\0' ' ' < файлзаменяет нулевые байты на пробелы (<подаёт файл на вход);echoв конце нужен, чтобы приглашение началось с новой строки;grep -E 'Name|State|PPid|Threads'выбирает четыре строки (-Eвключает|как «или»);ls -l ... | awk 'NR>1 {print $9, $10, $11}'берёт из длинного листинга только имя дескриптора, стрелку и то, куда он ведёт (NR>1пропускает первую строкуtotal 0);readlinkпечатает, на что указывает ссылка.
PID=$(pgrep -f 'python3 app.py')
tr '\0' ' ' < /proc/$PID/cmdline; echo
grep -E 'Name|State|PPid|Threads' /proc/$PID/status
ls -l /proc/$PID/fd | awk 'NR>1 {print $9, $10, $11}'
readlink /proc/$PID/cwd
readlink /proc/$PID/exe
Что должно получиться:
[1] 179
PID сервиса: 179
179 python3 app.py
PID PPID USER STAT ELAPSED CMD
179 178 ubuntu S 00:02 python3 app.py
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=179,fd=3))
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 179 ubuntu 3u IPv4 66737 0t0 TCP localhost:http-alt (LISTEN)
python3 app.py
Name: python3
State: S (sleeping)
PPid: 178
Threads: 1
0 -> /dev/pts/0
1 -> /dev/pts/0
2 -> /home/ubuntu/notes/app.log
3 -> socket:[66737]
/home/ubuntu/notes
/usr/bin/python3.12
Как читать вывод:
[1] 179: bash сообщает «задание номер 1, PID 179». Следующая строка это нашecho.- В
psколонкаPPID(178) это PID твоей оболочки.STATравенS: процесс спит и ждёт запросов. Знакаlнет, потому что поток один, знака+нет, потому что процесс в фоне.ETIME(00:02) показывает, сколько секунд он работает. - В
ssпорт127.0.0.1:8080иusers:(("python3",pid=179,fd=3))говорят, кто слушает и какой дескриптор держит сокет.lsofпоказывает то же самое другими словами:3uзначит дескриптор 3 открыт на чтение и запись,localhost:http-altэто127.0.0.1:8080(у 8080 есть имя в списке известных портов). cmdlineпечатаетpython3 app.pyс пробелом на конце: последний нулевой байт превратился в пробел.- Дескрипторы: 0 и 1 смотрят в терминал (
/dev/pts/0), потому что мы ничего не перенаправляли; 2 ведёт вapp.log, потому что мы перенаправили2>>; 3 это сокет. На Ubuntu 26.04 уls -lв этой строке появится ещё знак+после прав, это дополнительные права, они нам не мешают.
Типичные ошибки:
bash: pgrep: command not found: не стоит пакет procps:sudo apt install -y procps.- Столбец
users:пуст в выводеss: процесс чужой, аsudoне указан:sudo ss -tlnp. pgrep app.pyбез-fничего не печатает (код 1): без-fшаблон сравнивается с именем программы (python3), а не с аргументами.pgrep -f app.pyнаходит лишние строки, если шаблон слишком общий: делай его точнее, например'python3 app.py'.ls: cannot read symbolic linkили пустойfdу чужого процесса: чужие дескрипторы читает только root, используйsudo.
Сервис пусть работает, он понадобится в задании 4.
Не понимаешь строку из
psилиtop? Скопируй её нейросети вместе с заголовком колонок и спроси по каждой колонке, что она значит. Потом проверь ответ: колонку вman ps, а число сверь с/proc/PID/status.
Задание 2. Сигналы и коды выхода
Цель: отправить процессу разные сигналы и увидеть, как меняются его состояние и код выхода. Подопытным будет sleep 300: он просто ждёт пять минут.
Предскажи: какой код выхода даст sleep, если послать ему kill -TERM, и что покажет echo $? сразу после самой команды kill?
Ответ
У sleep будет 143 (128 + 15), но сразу после kill echo $? покажет 0: это код самой команды kill, она отправила сигнал без ошибки. Настоящий код процесса получают через wait.
Шаги. Все команды выполняй в одном терминале, по одной.
- Запусти
sleepв фоне и посмотри состояние. Разбор:ps -o pid,stat,cmd -p $!показывает три колонки только для PID из$!.
sleep 300 &
ps -o pid,stat,cmd -p $!
- Заморозь и разморозь:
kill -STOPиkill -CONT(сигналы 19 и 18).
kill -STOP $!
ps -o pid,stat,cmd -p $!
kill -CONT $!
ps -o pid,stat,cmd -p $!
- Проверь «живость» сигналом 0, потом останови процесс мягко и получи настоящий код выхода. Разбор:
wait $!ждёт фоновый процесс и возвращает его код;;разделяет команды на одной строке.
kill -0 $!; echo "kill -0 вернул $?"
kill -TERM $!; echo "код самой kill: $?"
wait $!; echo "код выхода sleep: $?"
- То же для SIGKILL и SIGHUP. Каждую пару выполняй по очереди.
sleep 300 & kill -KILL $!; wait $!; echo "после SIGKILL: $?"
sleep 300 & kill -HUP $!; wait $!; echo "после SIGHUP: $?"
- Проверь, что процесса после смерти нет, и как
kill -0это показывает:
sleep 300 & PID=$!
kill $PID; wait $PID; echo "код: $?"
kill -0 $PID; echo "kill -0 вернул $?"
Ctrl+Cв переднем плане. Запустиsleep 300без&, нажмиCtrl+Cи посмотри код:
sleep 300
echo "код выхода: $?"
- Поймай сигнал в самой оболочке.
trap 'КОМАНДА' СИГНАЛв bash говорит «когда придёт этот сигнал, выполни команду»;$$это PID самой оболочки. Обработчик в bash это то же самое, чтоsignal.signalв Python (подробнее проtrapв уроке 1.7).
trap 'echo поймал TERM' TERM
kill -TERM $$
trap - TERM
Последняя строка снимает ловушку.
Что должно получиться:
[1] 197
PID STAT CMD
197 S sleep 300
[1]+ Stopped sleep 300
PID STAT CMD
197 T sleep 300
PID STAT CMD
197 S sleep 300
kill -0 вернул 0
код самой kill: 0
[1]+ Terminated sleep 300
код выхода sleep: 143
[1]+ Killed sleep 300
после SIGKILL: 137
[1]+ Hangup sleep 300
после SIGHUP: 129
код: 143
bash: kill: (204) - No such process
kill -0 вернул 1
^C
код выхода: 130
поймал TERM
Точная позиция строк вроде [1]+ Stopped может отличаться: bash печатает их между командами, когда замечает смену состояния.
Как читать вывод:
Sменяется наTпослеkill -STOPи возвращается вSпослеkill -CONT. Процесс не умирал: его просто не планировали на процессор.kill -0возвращает 0, пока процесс существует и тебе можно ему слать сигналы. После смерти вместо 0 приходит ошибкаNo such processи код 1.- Три кода в задании: 143, 137, 129, ровно как в таблице кодов из теории (128 плюс 15, 9, 1).
Terminated,Killed,Hangup: так bash называет причину смерти задания.^Cэто то, что терминал печатает на нажатиеCtrl+C, а130это 128 плюс 2 (SIGINT).- Ловушка в конце показывает, что сигнал SIGTERM можно поймать: оболочка напечатала фразу и осталась жива. То же самое сделает v2.1 для сервиса.
Типичные ошибки:
bash: kill: (204) - No such process: процесс уже завершился, PID устарел. Повтори запуск.bash: kill: %1: no such job: номер задания неверный, посмотриjobs.bash: wait: pid 204 is not a child of this shell:waitработает только для заданий этой же оболочки. Не открывай дляwaitдругой терминал.- Оболочка «умерла» после
kill -TERM $$до того, как ты поставилtrap:$$это твоя оболочка, и SIGTERM убивает её без обработчика; открой новый терминал и повтори в правильном порядке. kill: Operation not permitted: процесс чужой, нуженsudo(урок 1.3).
Задание 3. Зомби, приоритет и нагрузка
Цель: своими руками получить зомби и убрать его, потом увидеть, как nice делит процессор, и прочитать load average.
Предскажи: что будет с зомби, если по нему выстрелить kill -9? А если остановить его родителя?
Ответ
По зомби kill -9 проходит молча и ничего не меняет: он уже мёртв. Если остановить родителя (kill без ключа), зомби усыновит PID 1, немедленно вызовет wait, и запись исчезнет.
Шаги.
- Создай «плохого родителя». Мы кладём Python-код в файл (так
psпокажет читаемую команду). Разбор heredoc:cat > файл <<'EOF' ... EOFзаписывает всё до строкиEOFв файл, кавычки вокругEOFотключают подстановки. Сам код:os.fork()создаёт копию процесса; в копии (pid == 0) вызываемos._exit(0), то есть потомок сразу завершается; родитель печатает PID-ы и засыпает на 600 секунд, ни разу не вызвавwait.
cat > /tmp/zombie.py <<'EOF'
import os, time
pid = os.fork()
if pid == 0:
os._exit(0) # потомок сразу завершается
print("родитель", os.getpid(), "потомок", pid, flush=True)
time.sleep(600) # родитель спит и не вызывает wait()
EOF
python3 /tmp/zombie.py &
sleep 1
ps -o pid,ppid,stat,cmd --ppid $!
--ppid $! выбирает процессы, у которых родитель равен нашему фоновому процессу.
- Найди всех зомби в системе одной командой. Разбор:
ps -eo pid,ppid,stat,cmdвыводит все процессы (-e) в выбранных колонках;awk '$3 ~ /^Z/'печатает строки, где третье поле (STAT) начинается наZ(~значит «соответствует шаблону»,^начало строки). Строка заголовка отфильтруется.
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
- Выстрели в зомби, а затем останови родителя.
%1это номер задания из bash:kill %1останавливает задание 1 (наш родитель).sleep 1даёт PID 1 время всё убрать.
ZOMBIE=$(ps -eo pid,stat | awk '$2 ~ /^Z/ {print $1}')
kill -9 "$ZOMBIE"; sleep 1; ps -o pid,stat,cmd -p "$ZOMBIE"
kill %1; sleep 1; ps -o pid,stat,cmd -p "$ZOMBIE"
- Приоритет. Запусти два вечных процесса
yes(он бесконечно печатаетy; вывод уходит в/dev/null, то есть «в никуда») и привяжи оба к нулевому ядру:taskset -c 0(ограничить процесс одним ядром). Второй пусть будет вежливым:nice -n 10. Второйpsсмотрит на%CPU,top -b -n 1 -pпечатает одну «фотографию» вместо живого экрана (-bпакетный режим для скриптов,-n 1один кадр),pgrep -d, -x yesсобирает PID в список через запятую по точному имени (-x).
taskset -c 0 yes > /dev/null &
taskset -c 0 nice -n 10 yes > /dev/null &
sleep 3
ps -o pid,ni,pcpu,stat,cmd -C yes
top -b -n 1 -p "$(pgrep -d, -x yes)" | tail -3
kill %1 %2
- Посмотри нагрузку и число ядер.
nprocпечатает число ядер,/proc/loadavgхранит те же числа, чтоuptime, плюс счётчики процессов.
uptime
nproc
cat /proc/loadavg
- Проверь права на
niceи поменяй приоритет своей оболочки.renice -n 15 -p $$делает оболочку вежливее.
nice
nice -n 5 nice
nice -n -5 sleep 1
renice -n 15 -p $$
Что должно получиться:
[1] 217
родитель 217 потомок 218
PID PPID STAT CMD
218 217 Z [python3] <defunct>
218 217 Z [python3] <defunct>
PID STAT CMD
218 Z [python3] <defunct>
PID STAT CMD
PID NI %CPU STAT CMD
1093 0 91.3 R yes
1094 10 9.5 RN yes
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1093 ubuntu 20 0 2656 1512 1424 R 100.0 0.0 0:04.66 yes
1094 ubuntu 30 10 2656 1512 1424 R 10.0 0.0 0:00.43 yes
11:54:21 up 26 min, 0 user, load average: 1.30, 0.55, 0.21
15
1.30 0.55 0.21 3/738 235
0
5
nice: cannot set niceness: Permission denied
216 (process ID) old priority 0, new priority 15
(Строки родитель ... и [1] 217 могут появиться в другом порядке. Числа PID, нагрузка и время у тебя другие. Значения %CPU могут колебаться, но соотношение примерно 9:1 сохранится. Кусок про два yes запускался отдельно от зомби, отсюда другие PID.)
Как читать вывод:
- У потомка
Zи вCMD[python3] <defunct>: квадратные скобкиpsставит вместо командной строки у процессов, у которых её уже нет. PPID 217 указывает на виноватого. - Вторая находка та же самая (мы искали
awk-ом). Послеkill -9 218зомби остался,killничего не сообщил: сигнал зомби не нужен. После остановки родителя вps -pостался только заголовок: PID 1 забрал запись. NI10 и состояниеRN: R значит «готов к работе», N значит «приоритет ниже обычного». Оба процесса хотят одно ядро, поэтому у вежливого около 9% против 91% у обычного. ВtopPR(внутренняя очередь планировщика) выросло с 20 до 30 приNI10.load average: 1.30, 0.55, 0.21: за минуту очередь в среднем 1,3, за пять 0,55, за пятнадцать 0,21, то есть нагрузка растёт. Делим наnproc(15 ядер): машина не перегружена. В/proc/loadavgпервые три числа те же, четвёртое3/738значит «3 процесса сейчас готовы к работе из 738», последнее число это PID, выданный последним.niceбез аргументов печатает текущее значение (0),nice -n 5 niceпоказывает 5, аnice -n -5без root заблокирован.reniceпечатает старое и новое значения приоритета для процесса с PID оболочки.
Типичные ошибки:
bash: kill: (218) - No such process: зомби уже убран, PID устарел.bash: kill: %1: no such job: номер задания другой. Посмотриjobsи подставь его.nice: cannot set niceness: Permission denied: понижать значение ниже 0 может только root. Обычному пользователю можно только повышать.taskset: command not found: пакетutil-linux,sudo apt install -y util-linux.python3 -c '...'вместо файла: вpsвместо аккуратной команды будет длинный кусок кода, поэтому мы кладём его в/tmp/zombie.py.
Сервис не стартует и пишет, что порт занят? Дай нейросети вывод
ss -tlnpиps -o pid,user,args -p <PID>. Пусть она назовёт, чей это процесс и как его остановить безkill -9. Но сначала проверь, не твой ли это прошлый запуск.
Задание 4. Порт занят: Address already in use
Цель: воспроизвести самую частую ошибку запуска сервиса, найти виновника и остановить его правильно.
Предскажи: запустится ли вторая копия app.py на том же порту? Что напишет и с каким кодом завершится?
Ответ
Нет. bind на занятый порт вернёт ошибку EADDRINUSE, Python выбросит OSError: [Errno 98] Address already in use и завершится с кодом 1.
Шаги.
- Сервис из задания 1 всё ещё работает (проверь
pgrep -af app.py; если нет, запусти его снова). Запусти второй экземпляр на том же порту.$?сразу после команды содержит её код выхода.
cd ~/notes
NOTES_DATA=/tmp/notes-demo2.txt python3 app.py
echo "код выхода: $?"
- Найди виновника и останови его мягко. Разбор:
lsof -t -i :8080печатает только PID (для своих процессовsudoне нужен);"$(...)"подставляет его вkill;sleep 1ждёт освобождения порта;||выполняет правую часть, только если левая завершилась ошибкой (grepничего не нашёл).
lsof -t -i :8080
kill -TERM "$(lsof -t -i :8080)"
sleep 1
ss -tlnp | grep 8080 || echo "порт свободен"
-
Запусти второй экземпляр ещё раз: теперь он поднимется. Останови его
Ctrl+Cи посмотри код (echo $?). Это пока версия v2, дальше починим. -
Пара ловушек. Ошибки порта и шаблона:
PORT=abc python3 app.py; echo "код: $?"
bash -c 'pkill -f "python3 app.py"; echo "после pkill"'; echo "код: $?"
Первая команда покажет ошибку из самого app.py (мы там проверяли PORT). Вторая показывает подвох pkill -f: шаблон совпал с командной строкой самой оболочки bash -c '...' (в ней тоже написано python3 app.py), и она убила себя.
Что должно получиться:
Notes vdev слушает 127.0.0.1:8080, данные /tmp/notes-demo2.txt
Traceback (most recent call last):
File "/home/ubuntu/notes/app.py", line 101, in <module>
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
...
OSError: [Errno 98] Address already in use
код выхода: 1
179
[1]+ Terminated NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log
порт свободен
ошибка: PORT должен быть числом
код: 2
Terminated
код: 143
Между serve_forever() и OSError Python печатает несколько внутренних строк (я их заменил на ...), а в Python 3.14 (Ubuntu 26.04) добавятся значки ~~~^^^ под строками кода и могут поменяться номера строк. Важна последняя строка.
Шаг 3 в выводе не показан: после Ctrl+C на v2 будет трассировка с KeyboardInterrupt и код 130 (v2 не ловит SIGINT, v2.1 научится).
Как читать вывод:
- Traceback читают снизу вверх: последняя строка
OSError: [Errno 98] Address already in useэто причина, выше путь по коду.Errno 98это номер ошибки ядра (EADDRINUSE). - Код 1 при необработанном исключении Python выдаёт сам. Первая строка
Notes vdev слушает ...напечатана до попыткиbind: программа заявляет «слушаю», а на деле не получилось. Это ошибка v2, которую мы поправим в задании 5. 179это ответlsof -t: PID владельца порта. Послеkill -TERMbash пишетTerminatedдля фонового задания. Пустой выводgrepипорт свободенподтверждают, что порт свободен.код: 2послеPORT=abc:app.pyсам выходит с кодом 2 при неверномPORT.- Последний пример:
Terminatedи код 143 показывают, что процесс, выполнявшийpkill, убил сам себя.
Типичные ошибки:
OSError: [Errno 98] Address already in use: порт занят. Найди процесс (lsof -i :8080) и останови.PermissionError: [Errno 13] Permission denied: попытка занять порт ниже 1024 без прав (например,PORT=80). На Ubuntu из ядра стоит порог 1024, в контейнерах Docker он может быть 0 и ошибки не будет. Наш порт 8080, значит, кто-то изменил переменнуюPORT. (Не прогонялось на стенде в чистом виде, только после снижения порога черезsysctl.)bash: lsof: command not found:sudo apt install -y lsof.pkill -fубил лишнее: слишком общий шаблон. Проверьpgrep -af ШАБЛОНдоpkill.
Задание 5. Шаг проекта: «Заметки» v2.1 корректно завершаются по SIGTERM
Цель: увидеть, как v2 умирает от SIGTERM (код 143, обрыв запроса), внести правки в app.py, получить код выхода 0 и убедиться, что файл совпадает с эталоном.
Предскажи: какой код выхода будет у v2 после kill -TERM, и какой у v2.1? Что случится с медленным запросом, который идёт в момент сигнала?
Ответ
У v2 код 143 (128 + 15): Python не перехватывает SIGTERM и умирает сразу, медленный запрос обрывается. У v2.1 код 0: обработчик останавливает сервер, а открытый запрос дорабатывается.
Шаги.
- Убедись, что порт свободен (
ss -tlnp | grep 8080пусто). Посмотри на v2. Разбор:APP=$!запоминает PID;wait "$APP"ждёт завершения;tail -n 3 app.logпоказывает последние 3 строки лога.
cd ~/notes
rm -f /tmp/notes-demo.txt app.log
NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log &
APP=$!
sleep 1
kill -TERM "$APP"
wait "$APP"
echo "код выхода v2: $?"
cat app.log
- Медленный запрос к v2. Разбор
curl:-sSтихий режим, но с ошибками;--limit-rate 5ограничивает скорость до 5 байт в секунду, поэтому тело из ~20 байт уходит около четырёх секунд;-X POST -d '...'отправляет POST с телом;-w '\nHTTP %{http_code}\n'печатает код ответа;&в конце уводит curl в фон, чтобы мы успели послать сигнал.
NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log &
APP=$!
sleep 1
curl -sS --limit-rate 5 -X POST -d '{"text":"slow note"}' -w '\nHTTP %{http_code}\n' http://127.0.0.1:8080/notes &
sleep 1
kill -TERM "$APP"
wait "$APP"; echo "код выхода v2: $?"
- Внеси четыре правки в
app.py(откройnano app.py, редактор из урока 1.1; номер строки можно указать при открытии:nano +99 app.py). Нужно, чтобы файл стал в точности версией v2.1.
Правка 1. Вторая строка файла (docstring, строка описания в тройных кавычках) должна стать такой:
"""Заметки v2.1: файловое хранилище и корректная остановка по сигналам."""
Правка 2. Импорты в начале файла идут в алфавитном порядке, между import json и import os добавь две строки:
import logging
import signal
Правка 3. Сразу после блока try/except про PORT (после строки sys.exit(2) и пустой строки) и перед строкой LOCK = ... вставь настройку логов. Что здесь происходит: LOG_LEVEL берётся из переменной окружения (по умолчанию info) и приводится к верхнему регистру; logging.basicConfig задаёт, что сообщения идут в stderr (тот самый app.log), с уровнем из LOG_LEVEL (если название неизвестное, берётся INFO) и форматом «время УРОВЕНЬ текст»; log это именованный логгер.
LOG_LEVEL = os.environ.get("LOG_LEVEL", "info").upper()
logging.basicConfig(
stream=sys.stderr,
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("notes")
Правка 4. В самом конце файла удали три последние строки: if __name__ == "__main__":, print(f"Notes v{VERSION} ...") и ThreadingHTTPServer(...).serve_forever(). На их место вставь целиком:
STOP_TIMEOUT = 10 # секунд на дорабатывание открытых запросов
def main():
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.daemon_threads = False # server_close() ждёт обработчики запросов
stopping = threading.Event()
def on_signal(signum, frame):
# второй сигнал во время остановки: выходим немедленно, код 1
if stopping.is_set():
os._exit(1)
stopping.set()
logging.info("shutting down")
# shutdown() ждёт выхода из serve_forever(), поэтому вызываем его
# из отдельного потока, а не из обработчика сигнала
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)
logging.info("started host=%s port=%s", HOST, PORT)
server.serve_forever()
# сюда попадаем после shutdown(): ждём открытые запросы не дольше STOP_TIMEOUT
watchdog = threading.Timer(STOP_TIMEOUT, lambda: os._exit(1))
watchdog.daemon = True
watchdog.start()
server.server_close() # дожидается потоков обработчиков
watchdog.cancel()
logging.info("stopped")
sys.exit(0)
if __name__ == "__main__":
main()
Каждый шаг этого блока уже разобран в теории про graceful shutdown: обработчик on_signal, отдельный поток для shutdown(), server_close(), сторожевой таймер на STOP_TIMEOUT секунд и выход с кодом 0. Последние две строки стандартный приём Python: запустить main(), только если файл запущен напрямую.
Сохрани файл (Ctrl+O, Enter, Ctrl+X).
4а. Проверь, что файл совпадает с эталоном. Разбор: curl -fsSL скачивает файл (-f считать HTTP-ошибку ошибкой, -s без индикатора, -S показывать ошибки, -L следовать перенаправлениям); diff - app.py сравнивает поток из stdin (-) с твоим файлом; && выполнит echo только при успехе, то есть если различий нет.
python3 -m py_compile app.py && echo "синтаксис ok"
curl -fsSL https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/versions/v2.1.py | diff - app.py && echo "совпадает с эталоном"
grep -n 'signal.signal' app.py
- Запусти v2.1 и останови её. Значение
APP_VERSIONв файле не трогаем.
rm -f /tmp/notes-demo.txt app.log
NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log &
APP=$!
sleep 1
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/healthz
kill -TERM "$APP"
wait "$APP"
echo "код выхода v2.1: $?"
cat app.log
- Медленный запрос к v2.1, как в шаге 2:
rm -f /tmp/notes-demo.txt app.log
NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log &
APP=$!
sleep 1
curl -sS --limit-rate 5 -X POST -d '{"text":"slow note"}' -w '\nHTTP %{http_code}\n' http://127.0.0.1:8080/notes &
sleep 1
kill -TERM "$APP"
wait "$APP"; echo "код выхода v2.1: $?"
cat /tmp/notes-demo.txt
cat app.log
- Для сравнения: SIGKILL. Обработчик тут бессилен.
NOTES_DATA=/tmp/notes-demo.txt python3 app.py 2>> app.log &
APP=$!; sleep 1
kill -KILL "$APP"; wait "$APP"; echo "код выхода при SIGKILL: $?"
Что должно получиться:
код выхода v2: 143
Notes vdev слушает 127.0.0.1:8080, данные /tmp/notes-demo.txt
curl: (52) Empty reply from server
HTTP 000
код выхода v2: 143
синтаксис ok
совпадает с эталоном
127: signal.signal(signal.SIGTERM, on_signal)
128: signal.signal(signal.SIGINT, on_signal)
200
код выхода v2.1: 0
2026-09-30 11:55:38,433 INFO started host=127.0.0.1 port=8080
2026-09-30 11:55:39,420 INFO shutting down
2026-09-30 11:55:39,926 INFO stopped
{"id": 1}
HTTP 201
код выхода v2.1: 0
{"id": 1, "text": "slow note", "created_at": "2026-09-30T12:11:50+00:00"}
... INFO shutting down
127.0.0.1 "POST /notes HTTP/1.1" 201 -
... INFO stopped
код выхода при SIGKILL: 137
Строки лога начинаются с даты и времени, у тебя они будут своими. Проверка эталона через curl работает, когда файл опубликован в репозитории курса; если сети нет, сравни с копией эталона из репозитория курса (diff путь/к/v2.1.py app.py) или просто проверь три признака: py_compile, grep и код выхода. В файле /tmp/notes-demo.txt хранится одна JSON-строка на заметку, время created_at у тебя будет своё.
Как читать вывод:
- Первый блок показывает v2: SIGTERM убил процесс (143), в логе только строка старта, а медленный запрос закончился обрывом
Empty reply from server(код 52 у curl,HTTP 000значит «ответа не было»). Заметка не записана: файла/tmp/notes-demo.txtнет. grepпоказывает номера строк сsignal.signal; у эталона это 127 и 128. Если у тебя другие, проверь, что вставки сделаны.200от/healthz: сервис жив. Затем код 0 и три строки лога:started,shutting down,stopped. Междуshutting downиstoppedполсекунды:serve_forever()выходит по 0,5-секундному опросу.- Медленный запрос в v2.1:
{"id": 1}иHTTP 201, код 0, а в логеshutting downидёт раньше строки о запросе,stoppedпозже: остановка началась при живом запросе и дождалась его. - SIGKILL: 137 и только строка
startedв логе, обработчик не сработал.
Типичные ошибки:
NameError: name 'signal' is not defined: не добавлены импортыloggingиsignal(правка 2).NameError: name 'log' is not definedилиloggingне настроен,INFOстроки не выводятся: пропущена правка 3.- В диффе видны отличия только в строке
import: порядок импортов другой, поправь на алфавитный. wait: pid 4400 is not a child of this shell:waitработает только для процессов, запущенных этой же оболочкой через&.- Код 143 после правки: файл не сохранён или запущена старая копия. Проверь
pgrep -af app.pyиgrep -n 'signal.signal' app.py. - Код 130 при
Ctrl+Cвместо 0: остался запущенным старый процесс; найди егоpgrep -af app.py. - Открытый запрос всё равно оборвался: пропущена строка
server.daemon_threads = False.
Итог задания: в ~/notes лежит v2.1 (обработчик SIGTERM/SIGINT, shutting down, выход с кодом 0, логи с уровнем). Долг остался один: сервис по-прежнему запускается руками и не поднимется сам после падения. Его закроет урок 1.8. Эталон: project/notes/versions/v2.1.py.
Сломай и почини
Скрипт создаёт три поломки. Читать его код не нужно, это часть упражнения: диагностируй сам. Скачай его и запусти первый сценарий (нужен sudo, потому что третий сценарий монтирует файловую систему; сервис при этом запускается от твоего пользователя). Перед этим должен быть пройден урок 1.3 и в ~/notes должен лежать app.py (лучше версии v2.1 из задания 5, но подойдёт и v2):
curl -fsSL -o /tmp/break-1.4.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/1.4/break.sh
sudo bash /tmp/break-1.4.sh 1
Разбор: curl -fsSL -o ФАЙЛ АДРЕС скачивает адрес в файл (ключи -fsSL разобраны в задании 5), sudo bash файл 1 запускает скрипт с аргументом «сценарий 1». Остальные сценарии запускаются с номерами 2 и 3, а fix возвращает исходное состояние: sudo bash /tmp/break-1.4.sh fix. Между сценариями всегда делай fix: сценарии не рассчитаны на запуск друг поверх друга. Перед стартом убедись, что твой сервис из заданий остановлен и порт 8080 свободен: скрипт проверит это и откажется, если порт занят.
Симптом
- Сценарий 1:
cd ~/notes && python3 app.pyпадает при старте сOSError: [Errno 98] Address already in use, хотя ты ничего не менял. Сервис недоступен. - Сценарий 2: после аварийного
kill -9скрипт запускаbash /tmp/notes-start.shотвечает, что сервис «уже запущен», аcurlна 8080 не отвечает. - Сценарий 3: в
psвисит процесс (три процесса) со статусомD,kill -9их не убирает, load average растёт при нулевой загрузке процессора. Запись в каталог/mnt/notes-breakзависает, чтение работает.
Гипотезы
- Порт занят другим процессом (возможно, старой копией сервиса).
- Остался PID-файл от процесса, которого уже нет, и скрипт верит файлу, а не реальности.
- Процессы ждут ввода-вывода в ядре: сигналы до них не доходят, пока операция не завершится.
Проверки
ss -tlnp | grep 8080 # кто слушает порт
lsof -i :8080
cat /tmp/notes.pid # что записано в PID-файле
ps -p "$(cat /tmp/notes.pid)" # жив ли такой PID
kill -0 "$(cat /tmp/notes.pid)"; echo $? # живой ли процесс: 0 да, 1 нет
sudo ps -eo pid,ppid,stat,wchan:22,cmd | awk '$3 ~ /^D/' # процессы в D и что они ждут
uptime # load average
findmnt /mnt/notes-break # чем смонтирован каталог
Разбор новых частей: wchan:22 добавляет колонку «где процесс ждёт внутри ядра» (wait channel), шириной 22 символа; awk '$3 ~ /^D/' оставляет строки, где третье поле (STAT) начинается с D; findmnt показывает, какое устройство и как подключено к каталогу. Для wchan нужен sudo: обычному пользователю ядро показывает там -.
Исправление
Разбор трёх сценариев
Сценарий 1 (висящий процесс держит порт). ss -tlnp или lsof -i :8080 показывает лишний python3 app.py (его данные лежат в /tmp/notes-break-stray.txt, это подсказка, что запуск был не твой). Останови его kill -TERM <PID>, подожди пару секунд, проверь, что порт свободен, и только при зависании kill -KILL <PID>. Запусти сервис заново. Причина: предыдущий запуск не был остановлен, а новый пытается занять тот же порт. Это то же самое, что в задании 4.
Сценарий 2 (устаревший PID-файл). cat /tmp/notes.pid показывает число, а ps -p и kill -0 сообщают, что такого процесса нет: сервис убит kill -9 и не успел удалить файл. Удали файл (rm /tmp/notes.pid) и запусти снова. Урок: PID-файл нельзя считать доказательством жизни процесса. Надёжная проверка: kill -0 <PID> (сигнал 0 только проверяет существование) или проверка порта. Именно поэтому менеджер сервисов (systemd, урок 1.8) следит за процессом сам, без PID-файлов. Заодно вспомни: SIGKILL нельзя поймать, поэтому программа не смогла убрать за собой (теория про сигналы).
Сценарий 3 (состояние D). Скрипт создаёт небольшой образ диска, монтирует его в /mnt/notes-break, запускает три процесса, которые раз в секунду пишут туда время, и «замораживает» файловую систему командой fsfreeze -f. Замороженная файловая система принимает чтение, но каждая запись ждёт «разморозки». Процессы записи стоят в ядре и показывают Ds, а в wchan видно имя ожидания (percpu_rwsem_wait, то есть «жду блокировку файловой системы»). Стек вызовов процесса можно посмотреть так: sudo cat /proc/<PID>/stack (увидишь percpu_rwsem_wait, vfs_write). Без sudo kill -9 даёт Operation not permitted (процессы запущены от root), а с sudo сигнал доставляется, но остаётся в ожидании: процесс не может обработать его, пока не вернётся из ядра. Чинить процессы не нужно, нужно устранить причину: разморозить файловую систему sudo fsfreeze -u /mnt/notes-break. После этого записи завершатся, а SIGKILL, ждавший своей очереди, доработает. Скрипт сам не размораживает: сценарий сам не закончится, нужен fix или эта команда. Вывод для эксплуатации: высокий load average при пустом процессоре (top показывает 99 id) это процессы в D, то есть проблема с диском, а не с процессором. В реальной жизни такое происходит, когда система резервного копирования или снапшотов заморозила файловую систему и не разморозила её обратно.
Не забудь вернуть всё в исходное состояние:
sudo bash /tmp/break-1.4.sh fix
ИИ в помощь
Нейросеть быстро расшифровывает колонки ps и top и подсказывает сигналы, но не видит твою машину: какой процесс завис и кто его родитель, она узнаёт только из того, что ты покажешь. Общие правила работы с ней: ИИ-помощник.
Задача: разобрать вывод ps или top.
Я на Ubuntu 24.04. Вот вывод ps aux и uptime (nproc = <число ядер>):
<вставь 10-15 строк и uptime>.
Объясни по колонкам, какие процессы подозрительны, и что значит load average
относительно числа ядер. Какие проверки сделать дальше, ничего не убивая?
Проверь ответ: сверь колонки с man ps и посмотри состояния процессов сам. Типичная ошибка: нейросеть читает load average как проценты или винит процессор, хотя причина в процессах в состоянии D.
Задача: выбрать сигнал для остановки.
Процесс <имя> (PID <номер>) не отвечает на kill. ps показывает состояние <буква>.
Объясни, чем SIGTERM, SIGINT, SIGHUP и SIGKILL отличаются, и предложи порядок действий,
который не потеряет данные. Скажи, когда kill -9 бессмысленен.
Проверь ответ: проверь состояние процесса в /proc/PID/status до и после сигнала. Типичная ошибка: совет сразу kill -9 или попытка убить зомби.
Задача: разобрать, кто занял порт и что делать.
Сервис не стартует: Address already in use на порту <порт>.
Вот вывод: ss -tlnp: <вывод>; ps -o pid,ppid,user,args -p <PID>: <вывод>.
Скажи, чей это процесс, не мой ли это прошлый запуск, и как остановить его аккуратно.
Проверь ответ: убедись, что PID из совета есть в выводе ss, прежде чем остановить его. Типичная ошибка: pkill -f с широким шаблоном, который заденет чужие процессы.
Словарик урока
| Термин | Простыми словами |
|---|---|
| Процесс (process) | запущенная программа: запись в таблице ядра с номером, владельцем, памятью и открытыми файлами |
| PID | номер процесса, уникальный, пока процесс жив |
| PPID | номер родителя, то есть процесса, который создал этот |
| PID 1 | самый первый процесс системы (у нас systemd); усыновляет сирот и убирает зомби |
| fork, exec | создать копию процесса и заменить в копии программу на другую: так запускается любая команда |
| Поток (thread) | «второй исполнитель» внутри процесса, делит с ним память и дескрипторы |
| Файловый дескриптор (fd) | номер «трубки» процесса к файлу или соединению: 0 ввод, 1 вывод, 2 ошибки |
| Сокет (socket) | «трубка» для сетевого соединения; слушающий сокет ждёт входящих клиентов |
| Порт (port) | число от 1 до 65535, по которому сетевая программа принимает соединения |
/proc |
каталог, в котором ядро показывает состояние процессов как файлы |
| Состояние R, S, D, Z, T | работает, спит, ждёт ядро (сигналы не действуют), зомби, остановлен |
| Сигнал (signal) | короткое уведомление процессу от ядра или другого процесса |
| SIGTERM (15) | «завершись сам»: можно поймать; шлёт kill без ключей |
| SIGKILL (9) | «умри немедленно»: ядро убивает процесс, поймать нельзя |
| SIGINT (2) | то же, что Ctrl+C |
| SIGHUP (1) | «терминал закрыт» или «перечитай конфиг» |
| SIGSTOP / SIGCONT | заморозить процесс / продолжить |
| Обработчик сигнала (handler) | код программы, который выполняется, когда пришёл нужный сигнал |
| Код выхода (exit code) | число, с которым процесс завершился: 0 успех, иначе ошибка; убитый сигналом получает 128 + номер |
$? |
переменная bash с кодом выхода последней команды |
| Фоновое задание (job) | процесс, запущенный с &, оболочка не ждёт его завершения |
nohup |
запуск, при котором процесс игнорирует SIGHUP |
Зомби (zombie, <defunct>) |
завершившийся процесс, код выхода которого родитель ещё не прочитал; держит только PID |
wait |
вызов, которым родитель забирает код выхода потомка (в bash так называется и команда) |
nice |
число от -20 до 19: чем больше, тем вежливее процесс к остальным |
| Load average | средняя длина очереди на процессор и диск за 1, 5 и 15 минут; сравнивают с nproc |
| Graceful shutdown | аккуратная остановка: поймать сигнал, дообработать открытые запросы, выйти с кодом 0 |
fsfreeze |
команда, которая замораживает запись в файловую систему (используют при снапшотах) |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Чем процесс отличается от потока?
Ответ
Процесс - это запущенная программа: у него свой PID, своё адресное пространство памяти и свои открытые файлы. Поток - это ветка выполнения внутри процесса. Потоки одного процесса делят общую память, поэтому создаются дешевле и быстро обмениваются данными, но ошибка в одном потоке может испортить данные всех, а аварийное завершение роняет весь процесс. Процессы изолированы друг от друга, между ними данные передают через сокеты, каналы или файлы. Число потоков процесса смотрю так: ps -o nlwp -p PID, а top -H показывает потоки отдельными строками.
Что хотят услышать: изоляция памяти у процессов против общей памяти у потоков, цена создания, риск гонок и падения всего процесса, как посмотреть потоки
Красный флаг: «поток - это маленький процесс» без слова про общую память
2. [junior] [часто] Чем SIGTERM отличается от SIGKILL и что ты пошлёшь первым?
Ответ
Первым SIGTERM: процесс может его поймать, закрыть соединения, дописать данные и убрать за собой. SIGKILL поймать нельзя, ядро убирает процесс сразу. Если через 10-30 секунд SIGTERM не сработал, шлю SIGKILL, потом разбираюсь, почему процесс не завершался сам.
Что хотят услышать: сначала TERM, потом KILL с паузой; KILL опасен потерей данных и мусором (PID-файлы, блокировки); связь с docker stop и Kubernetes.
Красный флаг: «всегда kill -9, так надёжнее».
3. [junior] [часто] В ps видишь процессы <defunct>. Что это и как убрать?
Ответ
Это зомби (zombie): процесс уже завершился, но родитель не прочитал код выхода через wait(). Убить зомби нельзя, он мёртв. Нужно найти родителя (ps -o ppid) и починить его или перезапустить: тогда зомби усыновит PID 1 и исчезнет.
Что хотят услышать: зомби не тратит CPU и память, но занимает PID; лечится через родителя; wait().
Красный флаг: «пошлю ему kill -9» или «зомби ест память».
4. [junior] [на скорость] Сервис не стартует: Address already in use. Твои действия?
Ответ
Смотрю, кто слушает порт: ss -tlnp | grep 8080 или sudo lsof -i :8080. Если это старая копия того же сервиса, останавливаю её через SIGTERM и запускаю заново. Если чужой процесс, выясняю, чей он и зачем, прежде чем убивать.
Что хотят услышать: ss или lsof, sudo для чужих процессов, проверка, что именно за процесс, а не «убить первого попавшегося»; упоминание TIME_WAIT (кратковременное состояние закрытого соединения, оно иногда блокирует порт после остановки) и SO_REUSEADDR (флаг, разрешающий занять порт сразу) плюс.
Красный флаг: «перезагружу сервер» или «поменяю порт, и всё».
5. [junior] Load average 8 при 4 ядрах. Что это значит?
Ответ
В среднем в очереди 8 задач на 4 ядра, то есть желающих вдвое больше, чем ядер. Но это очередь и на процессор, и на диск: процессы в D тоже считаются. Смотрю top: если процессор занят, ищу процесс-потребитель; если процессор свободен, а load высокий, ищу процессы в состоянии D и проблему с диском.
Что хотят услышать: сравнение с nproc; три значения (1, 5, 15 минут) и тренд; D-состояние в load; следующий шаг, не гадание.
Красный флаг: «load 8 это 800% нагрузки».
6. [junior] [на скорость] kill -9 не убивает процесс, он остаётся в ps. Как такое возможно?
Ответ
Два варианта. Процесс в состоянии D ждёт ядро, и сигнал доставится только по возвращении из вызова. Или это зомби: он уже мёртв, kill по нему не действует, надо разбираться с родителем.
Что хотят услышать: оба варианта, ps со столбцами stat и wchan, при D причины (диск, сетевая файловая система, драйвер, замороженная файловая система).
Красный флаг: «kill -9 убивает всё, значит, ps врёт».
7. [junior] Что такое &, nohup и чем они отличаются от сервиса под systemd?
Ответ
& отправляет процесс в фон, но при закрытии терминала он получит SIGHUP и умрёт. nohup игнорирует SIGHUP и переживёт закрытие терминала. Но ни то ни другое не поднимет процесс после падения или перезагрузки, поэтому для постоянных сервисов нужен systemd.
Что хотят услышать: SIGHUP; nohup как разовое решение; systemd для автозапуска, перезапуска и логов.
Красный флаг: «nohup и есть демон, systemd не нужен».
8. [middle] Прод отвечает 502, порт 8080 бэкенда не слушается. Твои действия?
Ответ
Проверяю, жив ли процесс: pgrep -af, ss -tlnp. Если процесса нет, смотрю, как он умер: код выхода (137 это SIGKILL или OOM, 143 это SIGTERM), журнал сервиса (journalctl, лог systemd, урок 1.8), сообщения ядра об нехватке памяти (dmesg | grep -i 'out of memory', урок 1.5). Если жив, но порт не слушает, смотрю логи старта и не привязался ли он к 127.0.0.1 вместо нужного адреса. Только после этого перезапускаю.
Что хотят услышать: последовательность, коды выхода, OOM-killer, логи до перезапуска (чтобы не потерять причину), потом устранение и мониторинг.
Красный флаг: «перезапускаю и смотрю, поможет ли».
9. [middle] Диск заполнен, а du показывает мало. Как найдёшь причину и как связаны процессы?
Ответ
Файл могли удалить, пока процесс держит его открытым: место занято, а в каталогах файла нет. Ищу sudo lsof +L1 (открытые файлы, у которых не осталось имён на диске) или ls -l /proc/<PID>/fd | grep deleted. Освободить место можно перезапуском процесса или, в крайнем случае, обнулением файла через : > /proc/<PID>/fd/N.
Что хотят услышать: удалённый, но открытый файл; lsof +L1, /proc/PID/fd; вторая причина - закончились inode (df -i, урок 1.5); процесс должен переоткрыть логи (SIGHUP или logrotate, программа ротации логов).
Красный флаг: «du -sh / покажет всё» или «ребутну».
10. [middle] После деплоя пользователи получают обрывы запросов ровно в момент перезапуска. В чём причина?
Ответ
Скорее всего, приложение не обрабатывает SIGTERM: оно умирает сразу, и открытые запросы обрываются, либо менеджер шлёт SIGKILL по таймауту, потому что приложение не завершается. Нужно поймать SIGTERM, перестать принимать соединения, дождаться текущих запросов с лимитом времени и выйти с кодом 0. В контейнере ещё проверить, что процесс это PID 1 и получает сигнал (exec-форма CMD, без оболочки-посредника).
Что хотят услышать: graceful shutdown, terminationGracePeriodSeconds и docker stop -t (время между SIGTERM и SIGKILL), preStop (команда, которую Kubernetes выполняет перед SIGTERM), балансировщик снимает узел до остановки.
Красный флаг: «увеличим количество реплик» без понимания причины.
11. [middle] Как узнать, чем занят «застрявший» процесс, если логов нет?
Ответ
По шагам: ps со столбцами stat и wchan, /proc/<PID>/status, ls -l /proc/<PID>/fd, ss -tnp для соединений, top -H -p <PID> для потоков. Если нужен ещё один слой, strace -p <PID> -f (программа, которая печатает системные вызовы процесса, то есть его просьбы к ядру) покажет, на каком вызове он стоит. На проде осторожно: замедляет процесс.
Что хотят услышать: /proc, lsof, strace с оговоркой про накладные расходы, различие «жду ввод-вывод» и «жгу процессор».
Красный флаг: «без логов ничего не узнать».
12. [junior] Чем kill отличается от pkill и killall, и чем опасно pkill java?
Ответ
kill PID шлёт сигнал процессу по номеру, по умолчанию SIGTERM. pkill шаблон выбирает процессы по имени (с -f по всей командной строке) и шлёт им сигнал, killall имя делает то же по точному имени. Опасность в широком совпадении: pkill java убьёт все Java-процессы, в том числе чужие сервисы на той же машине. Поэтому сначала проверяю, кого выберет шаблон: pgrep -a java. Для сервиса под systemd правильнее systemctl stop, а не убивать процессы.
Что хотят услышать: kill по PID, pkill/killall по имени, SIGTERM по умолчанию, pgrep -a перед убийством, systemctl для сервисов.
Красный флаг: «Убиваю всё подряд по имени pkill -9».
13. [junior] Что делают Ctrl+C, Ctrl+Z, bg, fg и jobs?
Ответ
Ctrl+C шлёт текущей программе SIGINT, обычно она завершается. Ctrl+Z шлёт SIGTSTP: процесс приостанавливается и остаётся в списке заданий этой оболочки. jobs показывает такие задания, bg %1 продолжает его в фоне, fg %1 возвращает на передний план. Запуск сразу в фоне: команда &. Но фоновые задания привязаны к терминалу: при закрытии сессии они могут получить SIGHUP, поэтому для долгих задач беру nohup, tmux или сервис systemd.
Что хотят услышать: SIGINT и SIGTSTP, jobs/bg/fg, &, SIGHUP при закрытии терминала, tmux/nohup/systemd.
Красный флаг: «Ctrl+Z закрывает программу».
14. [junior] [на скорость] Какие сигналы Linux самые популярные и что каждый делает?
Ответ
SIGTERM (15) просит завершиться, его можно поймать и аккуратно закрыться. SIGKILL (9) ядро исполняет сразу, поймать нельзя. SIGINT (2) приходит по Ctrl+C. SIGHUP (1) закрылся терминал, многие демоны по нему перечитывают конфиг. SIGSTOP приостанавливает процесс (поймать нельзя), SIGCONT продолжает его. SIGQUIT (Ctrl+\) завершает с дампом памяти. SIGUSR1 и SIGUSR2 приложение определяет само. Список смотрю kill -l, отправляю kill -TERM <PID>.
Что хотят услышать: TERM против KILL, что ловится и что нет, HUP для перечитывания конфига, kill -l.
Красный флаг: «все сигналы убивают процесс» или «kill всегда означает -9».
Проверено на версиях
Прогонялось в контейнерах Docker (на Mac), сценарии поломок в контейнере с systemd в привилегированном режиме:
- Ubuntu 24.04.5 LTS: Python 3.12.3, procps-ng 4.0.4 (
ps,top,pgrep,pkill,renice), lsof 4.95.0, bash 5.2.21, curl 8.5, ядро 6.12 (Docker Desktop). Прогонялись все задания 1-5 (интерактивные части через псевдотерминал, чтобы получить настоящие строки[1]+ Terminatedи^C), скриптbreak.sh(сценарии 1, 2, 3 иfix, включая повторныйfix). - Ubuntu 26.04 (образ без systemd): Python 3.14.4, procps-ng 4.0.4, lsof 4.99.4, bash 5.3.9, curl 8.18.0, iproute2 6.19.0. Задания 1-5 прогонялись; отличия только в формате Traceback (значки
~~~^^^), знаке+вls -l /proc/PID/fdи в больших номерах PID. - Проверка эталона:
app.pyиз правок урока собран автоматически и совпадает сproject/notes/versions/v2.1.pyбайт в байт. - Не прогонялось:
PermissionErrorнаPORT=80в чистом виде (в Docker порог непривилегированных портов равен 0, ошибка воспроизведена только послеsysctl -w net.ipv4.ip_unprivileged_port_start=1024); командаcurl ... | diff - app.pyдля эталона на GitHub (путь в репозитории проверен, публикация не проверялась); скриптbreak.shпроверенshellcheck, но не на виртуальной машине с настоящим ядром вместо Docker.
Итог урока: ты умеешь
- умею находить процесс по имени и порту (
pgrep,ss -tlnp,lsof -i) - умею читать PID, PPID, владельца и состояние (R, S, D, Z, T) в
ps - умею останавливать процесс правильно: TERM, пауза, только потом KILL
- умею по коду выхода понять причину: 143, 137, 130, 129
- умею находить зомби и его родителя и объяснять, почему
killпо зомби не работает - умею читать
/proc/<PID>/(cmdline,status,fd,cwd) - умею проверять «жив ли процесс» через
kill -0и понимать, почему PID-файлу верить нельзя - умею читать load average и отличать очередь на процессор от процессов в состоянии D
- умею объяснять, почему
app.pyv2.1 завершается с кодом 0, и показать это наkill -TERM
Дальше: Урок 1.5: Диск, память и процессор: где кончаются ресурсы
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.