Раздел 2 · Linux — глава 2.1

Linux: процессы, inode и файловые дескрипторы

«Расскажите про типы процессов» — вопрос, на который легко ответить заученным списком «демоны, зомби, сироты» и не сказать ничего. На самом деле за этим стоит одна довольно изящная механика — то, как процессы вообще рождаются, — и все «типы» из неё следуют. Разберём её, а потом посмотрим, во что она превращается в контейнере.

§1Откуда вообще берутся процессы

В Linux нет системного вызова «запусти программу». Есть два отдельных вызова, и это ключ ко всему остальному.

fork()клонирует текущий процесс. Был один — стало два, полностью одинаковых: тот же код, те же переменные, те же открытые файлы и сокеты. Отличаются они ровно одним: возвращаемым значением. Родителю fork() вернёт PID ребёнка, ребёнку — ноль. По этому нулю код и понимает, в какой из двух копий он сейчас исполняется.

exec()заменяет содержимое процесса другой программой. PID остаётся тем же, открытые файловые дескрипторы (если не помечены как close-on-exec) остаются, а вот код, данные и стек затираются новой программой. Процесс не создаётся — процесс перерождается.

Запуск команды в шелле — это связка из двух: fork(), чтобы получить копию, и exec() в ребёнке, чтобы превратить копию в нужную программу. Родитель тем временем зовёт wait() и ждёт.

bash (PID 4021) │ ├─ fork() ──→ bash-клон (PID 4099) │ │ │ └─ exec("/bin/ls") → теперь это ls, PID тот же 4099 │ │ └─ wait(4099) ←─────── exit(0) ────────────┘ получил код возврата, убрал запись из таблицы

Копирование целого процесса ради того, чтобы через миллисекунду затереть его другой программой, звучит расточительно — и было бы им, если бы память копировалась по-настоящему. Но fork() использует copy-on-write: страницы памяти помечаются только для чтения и разделяются между родителем и ребёнком, а физическое копирование происходит лениво, только для тех страниц, в которые кто-то записал. Поэтому fork() дёшев.

Грабли

Copy-on-write перестаёт быть бесплатным, когда родитель занимает много памяти и активно в неё пишет. Классика — Redis, который делает fork() для сохранения снапшота на диск: пока идёт запись RDB, каждая изменённая клиентом страница копируется, и потребление памяти может вырасти почти вдвое. Отсюда рекомендация держать под Redis запас RAM и включать vm.overcommit_memory=1.

§1.1Inode и файловые дескрипторы

Эти понятия часто спрашивают вместе, хотя они живут на разных уровнях. Inode описывает объект файловой системы, а file descriptor описывает доступ конкретного процесса к открытому объекту ядра.

имя /var/log/app.log │ запись в каталоге: имя → inode 4812 ▼ inode 4812 тип, права, UID/GID, размер, времена, link count указатели на блоки данных процесс nginx, PID 1203 fd 0 → /dev/null fd 1 ──→ open file description ──→ inode 4812 ──→ блоки данных fd 2 ──┘ offset, flags fd 7 → socket

Inode: файл без имени

Inode — структура метаданных файла или каталога внутри одной файловой системы. В ней находятся тип объекта, права, владелец UID/GID, размер, timestamps, число жёстких ссылок и информация о блоках данных. Имени и полного пути в inode нет. Имя хранится в каталоге: запись каталога связывает строку вроде app.log с номером inode.

$ ls -li app.log
4812 -rw-r----- 1 app app 7340032 Jul 31 12:10 app.log
^^^^ номер inode в этой файловой системе

$ stat app.log
  File: app.log
  Size: 7340032    Blocks: 14344    IO Block: 4096 regular file
Device: 8,1        Inode: 4812      Links: 1

Номер inode уникален только внутри конкретной файловой системы. Поэтому пара device + inode идентифицирует файл точнее, чем один номер.

Жёсткая и символическая ссылки

Hard linkSymlink
Что хранитЕщё одно имя того же inodeОтдельный inode с текстом пути к цели
После удаления исходного имениФайл доступен через второе имяСсылка становится битой
Через границу файловых системНельзя: inode принадлежит одной ФСМожно
На каталогОбычно запрещено, чтобы не создавать циклыМожно
$ ln app.log app.hard
$ ln -s app.log app.sym
$ ls -li app.log app.hard app.sym
4812 ... app.hard
4812 ... app.log          # один inode, Links теперь 2
9001 ... app.sym -> app.log

Что на самом деле делает rm

rm вызывает unlink(): удаляет из каталога связь «имя → inode» и уменьшает link count. Данные освобождаются только когда одновременно выполнены два условия:

  1. жёстких ссылок больше нет: link count равен нулю;
  2. не осталось открытых descriptors и других ссылок ядра, например активного mmap().

Отсюда классический инцидент: лог удалили, du его уже не видит, но процесс продолжает писать через открытый descriptor, а df показывает занятое место. Имени нет, inode и блоки ещё живы.

$ df -h /var
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1       100G   99G  1.0G  99% /var

$ lsof +L1
COMMAND  PID  FD  TYPE DEVICE  SIZE/OFF NLINK NAME
java    2201  5w  REG    8,1        18G     0 /var/log/app.log (deleted)

$ ls -l /proc/2201/fd/5
5 -> '/var/log/app.log (deleted)'

Правильное лечение — попросить приложение переоткрыть логи, если оно поддерживает такой сигнал, либо штатно перезапустить сервис. После исчезновения последней открытой ссылки ядро сразу освободит блоки. Не надо удалять другие файлы наугад; сначала lsof +L1.

Место есть, а файл создать нельзя: закончились inode

Во многих файловых системах, например ext4, число inode ограничено. Миллионы пустых файлов способны исчерпать inode раньше гигабайтов данных. Тогда df -h показывает свободное место, а touch отвечает No space left on device.

$ df -h /var             # свободные блоки
$ df -i /var             # свободные inode
Filesystem      Inodes  IUsed IFree IUse% Mounted on
/dev/sda1       6553600 6553600    0  100% /var

$ du --inodes -x -d1 /var | sort -n   # где больше всего объектов

Лечение — найти источник мелких файлов и удалить или архивировать их. Увеличение диска само по себе не всегда добавляет inode; решение зависит от файловой системы.

File descriptor: номер в таблице процесса

File descriptor (FD) — небольшое неотрицательное число, индекс в таблице открытых объектов конкретного процесса. По соглашению 0 — stdin, 1 — stdout, 2 — stderr. Descriptor бывает не только у обычного файла: те же номера используются для sockets, pipes, терминалов, eventfd и других объектов ядра.

open(), socket(), accept() и pipe() создают descriptors, close() освобождает. Сам FD ведёт к общей kernel-структуре open file description, где лежат текущая позиция чтения/записи и flags. После fork() родитель и ребёнок получают свои таблицы FD, но унаследованные descriptors ссылаются на те же open file descriptions — поэтому разделяют offset. exec() сохраняет FD, кроме помеченных close-on-exec.

Пример без магии

В команде ./app > app.log 2>&1 shell открывает app.log, через dup2() ставит его на FD 1, дублирует FD 1 в FD 2 и только потом делает exec(). Приложение ничего не знает о файле: оно просто пишет в stdout и stderr, а унаследованные descriptors уже ведут в лог.

Утечка descriptors

У процесса есть soft и hard limit на число открытых FD (RLIMIT_NOFILE). Если приложение забывает закрывать файлы, клиентские sockets или результаты accept(), оно доходит до soft limit и получает EMFILE: Too many open files. После этого web-сервер может продолжать числиться живым, но перестаёт принимать новые соединения и открывать логи.

$ ulimit -Sn                              # soft limit текущего shell
1024
$ ulimit -Hn                              # hard limit
1048576

$ grep 'open files' /proc/2201/limits
Max open files            65536                65536                files

$ find /proc/2201/fd -maxdepth 1 -type l | wc -l
64211
$ lsof -nP -p 2201                         # какие именно объекты открыты

Просто поднять лимит можно как временную меру, но это не исправляет утечку. Смотри, какой тип FD растёт: тысячи одинаковых sockets — не закрываются соединения, много файлов — не закрываются потоки, один удалённый гигантский лог — приложение не переоткрыло файл после ротации.

Короткий ответ на собеседовании

«Inode хранит метаданные и адреса блоков файла, но не имя: имя — запись каталога, указывающая на inode. File descriptor — процессный номер открытого файла, socket или pipe; через open file description он ведёт к inode или другому объекту ядра. Поэтому удалённый открытый файл продолжает занимать место до закрытия последнего FD, а утечка FD заканчивается Too many open files. Проверяю inode через stat и df -i, descriptors — через /proc/PID/fd и lsof, удалённые открытые файлы — lsof +L1.»

§2Состояния: что означают буквы в ps

Процесс в каждый момент находится в одном из нескольких состояний. Их видно в колонке STAT команды ps aux, и понимать их — половина навыка диагностики.

КодСостояниеЧто это на самом деле значит
RRunning / RunnableЛибо прямо сейчас на процессоре, либо стоит в очереди и готов исполняться. Много R при высоком load average — упёрлись в CPU.
SInterruptible sleepЖдёт события: данных из сокета, таймера, ввода. Прерывается сигналом. Абсолютное большинство процессов в системе — здесь, и это норма.
DUninterruptible sleepЖдёт завершения операции ввода-вывода в ядре и не реагирует ни на что, включая kill -9. Про это отдельный разговор ниже.
TStoppedПриостановлен сигналом SIGSTOP/SIGTSTP (Ctrl+Z) или отладчиком. Оживает по SIGCONT.
ZZombieУже завершился, но родитель не забрал код возврата. Занимает только строчку в таблице процессов.
IIdleПростаивающий поток ядра. Введён отдельно, чтобы такие потоки не накручивали load average.

Рядом с буквой состояния ps печатает модификаторы: s — лидер сессии, + — процесс в группе переднего плана, l — многопоточный, < — повышенный приоритет, N — пониженный. Строка Ssl читается как «спит, лидер сессии, многопоточный» — типичный демон.

$ ps aux | head -5
USER  PID  %CPU %MEM  STAT  COMMAND
root    1   0.0  0.1  Ss    /sbin/init            # спит, лидер сессии
root  842   0.3  2.1  Ssl   /usr/bin/dockerd      # демон, многопоточный
mysql 1203 12.4 41.2 Ssl   /usr/sbin/mysqld
user  9871  0.0  0.0  Z     [python] <defunct>    # зомби
user  9902 99.9  0.5  R+    /usr/bin/ffmpeg       # жрёт CPU на переднем плане

§3D-состояние: процесс, который не убивается

Это вопрос-ловушка почти на каждом собесе: «что делать, если kill -9 не убивает процесс?» Правильный ответ — «ничего, ждать или чинить хранилище».

Когда процесс делает read() с диска, он засыпает в ядре до прихода данных. Обычный сон (S) можно прервать сигналом: ядро разбудит процесс, тот обработает сигнал, вызов вернёт EINTR. Но некоторые операции прерывать нельзя — процесс уже отдал ядру буферы, драйвер уже начал DMA-передачу, структуры файловой системы в промежуточном состоянии. Разбудить процесс посреди этого — гарантированно испортить данные. Поэтому такой сон помечается как непрерываемый: сигналы копятся в очереди и будут доставлены, когда операция завершится. Включая SIGKILL.

Обычно это длится микросекунды, и в ps ты никогда этого не увидишь. Устойчивое D означает, что ввод-вывод не завершается: сдохший диск, отвалившийся NFS-сервер (самая частая причина — жёсткое монтирование hard без intr), потерянный LUN на SAN, залипший драйвер. Процесс висит, load average растёт (в Linux он считает и R, и D), а сделать с самим процессом нельзя ничего.

# посмотреть, где именно застрял процесс в ядре:
$ cat /proc/9871/stack
[<0>] nfs_wait_bit_killable+0x28
[<0>] rpc_wait_bit_killable+0x1a     ← NFS ждёт ответа сервера

# найти всех застрявших разом:
$ ps -eo pid,stat,wchan:30,comm | awk '$2 ~ /D/'

Лечится это не на уровне процесса: восстановить доступ к хранилищу, перемонтировать NFS, в безнадёжном случае — перезагрузить узел. И вот эту фразу на собесе стоит произнести дословно: «kill -9 тут бесполезен, потому что процесс не в user space — он внутри системного вызова, и сигнал ему просто не доставят, пока вызов не вернётся».

Спросят

«Load average 40, а CPU простаивает — что происходит?» — В Linux load average, в отличие от классического UNIX, включает процессы в D. Значит, десятки процессов ждут ввода-вывода. Смотреть надо не top, а iostat -x 1 (колонка %util и await) и ps с фильтром по D. Ответ «упёрлись в диск или в сеть до хранилища, не в процессор» — то, что хотят услышать.

§4Зомби и сироты: две противоположные ситуации

Их постоянно путают, хотя они прямо зеркальны друг другу.

Зомби — умер ребёнок, а родитель жив и не реагирует

Когда процесс завершается, ядро освобождает его память, закрывает файлы, отпускает всё — но оставляет запись в таблице процессов, в которой лежит код возврата. Эта запись нужна родителю: он имеет право спросить, чем закончился его ребёнок. Пока родитель не вызвал wait(), запись висит, и такой процесс называется зомби (в ps он показывается как <defunct>).

Короткоживущие зомби — норма, они существуют доли секунды между завершением ребёнка и wait() родителя. Проблема начинается, когда родитель написан плохо: плодит детей, а wait() не зовёт. Тогда зомби накапливаются.

Вреда от одного зомби меньше, чем принято думать: адресная память уже освобождена, процессор он не ест, открытых файлов у него нет. Остаётся небольшая запись ядра с PID, PPID, кодом выхода и статистикой. Но тысячи зомби занимают слоты таблицы процессов и учитываются в лимитах числа задач — системном pid_max, пользовательском nproc и container/cgroup pids.max. Когда лимит исчерпан, новые процессы и потоки не создаются: fork: Resource temporarily unavailable. Даже SSH-вход может перестать работать.

И главное: зомби нельзя убить. Он уже выполнил exit(), поэтому kill -9 ZOMBIE_PID не на что воздействовать. Запись должен забрать родитель вызовом wait()/waitpid(). Можно послать родителю SIGCHLD, но это поможет только если у него есть корректный обработчик, который вызывает wait. Надёжное оперативное решение — штатно перезапустить родительский сервис или контейнер. Тогда детей усыновит init/subreaper и соберёт; постоянное решение — исправить код родителя или добавить нормальный init.

$ ps -eo stat,ppid,pid,comm | awk '$1 ~ /Z/ { print }'
Z    3412   9871 python <defunct>
Z    3412   9872 python <defunct>    ← у всех один родитель: 3412
Z    3412   9873 python <defunct>

$ ps -p 3412 -o pid,ppid,stat,cmd
$ kill -CHLD 3412        # безопасная попытка, но сработает не всегда
$ systemctl restart app  # штатно перезапустить родительский сервис

Не делай вслепую

Не отправляй SIGKILL родителю, пока не понял, что это за процесс: вместе с ним можно оборвать запросы, транзакции и живых детей. Если PPID равен 1 на обычном хосте и зомби не исчезает, проблема уже в init/subreaper; если это PID 1 контейнера — перезапусти Pod и затем исправь ENTRYPOINT или добавь tini.

Сирота — умер родитель, а ребёнок продолжает работать

Обратная ситуация и, в отличие от зомби, совершенно здоровая. Если родитель завершился раньше ребёнка, ребёнок не умирает — он остаётся без присмотра. Ядро тут же назначает ему нового родителя: init (PID 1) или ближайшего subreaper — процесс, который явно заявил о готовности усыновлять через prctl(PR_SET_CHILD_SUBREAPER). Так работает, например, systemd для юнитов и контейнерные рантаймы.

Проверить просто: у сироты в колонке PPID станет 1. Именно на этом механизме держатся все демоны — см. следующий раздел.

В бою

Одной строкой посчитать зомби и понять, кто их плодит:
ps -eo ppid,stat | awk '$2~/Z/{c[$1]++} END{for (p in c) print c[p], "зомби от PPID", p}' | sort -rn
Если верхняя строка — PID 1 внутри контейнера, он не вызывает wait(): либо исправляй приложение как init, либо ставь tini (§7).

§5Демоны: как процесс отвязывается от терминала

Демон — это просто фоновый процесс, который не связан ни с каким терминалом и работает всё время жизни системы. Классический способ им стать («двойной форк») сейчас в основном исторический, но понимать его полезно, потому что он объясняет, зачем вообще нужны сессии и группы процессов.

  1. fork(), родитель немедленно выходит. Ребёнок становится сиротой, его усыновляет init — теперь он гарантированно не лидер группы процессов.
  2. setsid() — создаёт новую сессию и новую группу процессов, отвязываясь от управляющего терминала. Теперь SIGHUP при закрытии терминала до него не долетит.
  3. Второй fork() — чтобы процесс перестал быть лидером сессии и уже никогда не смог случайно захватить терминал.
  4. chdir("/") — чтобы не держать занятой какую-нибудь файловую систему и не мешать её размонтированию.
  5. umask(0), закрытие stdin/stdout/stderr и перенаправление их в /dev/null или в лог.

Сегодня всё это делает за тебя systemd, и писать демонизацию руками — антипаттерн. Правильный современный демон — это обычная программа, которая работает на переднем плане, пишет логи в stdout и не форкается вообще. Systemd сам поместит её в свой cgroup, отвяжет от терминала, соберёт вывод в журнал и перезапустит при падении.

Отсюда — типы юнитов в systemd, о которых любят спрашивать:

Type=Когда использовать
simpleПо умолчанию. Процесс работает на переднем плане, systemd считает его запущенным сразу после exec. Правильный выбор в 90% случаев.
execКак simple, но systemd ждёт успешного exec() перед тем, как считать юнит запущенным. Чуть честнее в диагностике ошибок запуска.
forkingДля старого софта, который сам демонизируется. Systemd ждёт выхода родителя и считает главным ребёнка. Требует PIDFile=, иначе systemd угадывает — и иногда неверно.
notifyПрограмма сама сообщает «я готова» через sd_notify(). Лучший вариант, если зависимые юниты должны стартовать строго после реальной готовности.
oneshotОтработал и вышел — миграции, подготовка окружения. В связке с RemainAfterExit=yes юнит считается активным и после выхода.

Отдельно про KillMode: по умолчанию (control-group) systemd при остановке отправляет сигнал всем процессам в cgroup юнита, а не только главному. Это то, что нужно почти всегда, — не остаётся осиротевших детей. Ставить KillMode=process стоит только осознанно, понимая, что дети переживут остановку сервиса.

§6Группы, сессии и почему процесс умирает при разрыве SSH

Три уровня группировки, которые существуют ради управления с терминала:

  • Группа процессов — конвейер целиком. Когда ты пишешь cat file | grep x | wc -l, все три процесса попадают в одну группу, и Ctrl+C шлёт SIGINT всей группе сразу, а не только последнему.
  • Сессия — набор групп, привязанных к одному терминалу. У сессии есть одна группа переднего плана (получает сигналы с клавиатуры и может читать с терминала) и сколько угодно фоновых.
  • Управляющий терминал — тот самый tty. Когда он исчезает, ядро отправляет SIGHUP лидеру сессии, а тот (обычно шелл) раздаёт SIGHUP своим заданиям.

Вот и весь механизм «запустил долгую задачу через SSH, соединение отвалилось, задача умерла». Способы этого избежать:

nohup ./long-job.sh &      # игнорировать SIGHUP, вывод → nohup.out
setsid ./long-job.sh       # новая сессия, терминала нет вообще
tmux new -s job            # честный способ: терминал живёт на сервере
systemd-run --unit=job ... # отдать systemd, получить логи и рестарты

Для чего-то серьёзнее пятиминутной задачи nohup — плохой выбор: нет логов в нормальном месте, нет перезапуска, нет способа узнать статус. tmux для интерактивной работы, systemd-run для всего остального.

§7PID 1 в контейнере: где это всё взрывается

Самая практически ценная часть главы, особенно если работаешь с Kubernetes.

Контейнер — это не виртуальная машина. Внутри него нет ни ядра, ни init-системы; это обычный процесс на хостовом ядре, просто помещённый в отдельные namespace'ы и cgroup. И в PID-namespace контейнера твоё приложение получает номер 1.

А у PID 1 в Linux особый статус, и он ломает два привычных предположения.

Проблема первая: сигналы по умолчанию не работают

Ядро не применяет к PID 1 действия по умолчанию для сигналов. Для обычного процесса SIGTERM без обработчика означает «умри». Для PID 1 без обработчика — ничего, сигнал просто отбрасывается. Логика ядра здравая: случайно убитый init — это паника системы.

Следствие в контейнере: если твоё приложение не ставит обработчик SIGTERM, оно его проигнорирует. docker stop отправит TERM, подождёт десять секунд, не дождётся и прибьёт контейнер через SIGKILL. В Kubernetes — то же самое, только ждать он будет terminationGracePeriodSeconds. Каждая выкатка становится жёстким убийством: оборванные запросы, незакрытые транзакции, потерянные буферы.

Проблема вторая: некому собирать зомби

Настоящий init постоянно вызывает wait() и прибирает всех усыновлённых сирот. Твоё приложение в роли PID 1 этого не делает — оно вообще не знает, что оно init. Если внутри контейнера что-то порождает дочерние процессы (шелл-обёртки, вызовы утилит из кода), зомби начинают копиться и в пределе исчерпают лимит PID в cgroup.

Отдельная ловушка: shell-обёртка съедает сигналы

Очень частая ошибка в Dockerfile:

CMD ./app --config /etc/app.yaml            # shell-форма!

# превращается в:  /bin/sh -c "./app --config ..."
# PID 1 — это sh, а ./app — его ребёнок с PID 7.
# SIGTERM приходит в sh, sh его не передаёт → приложение
# вообще не узнаёт, что его останавливают.

CMD ["./app", "--config", "/etc/app.yaml"]   # exec-форма, PID 1 — само приложение

Разница между shell-формой и exec-формой CMD/ENTRYPOINT — любимый вопрос на собесе, и как раз потому, что за ним стоит эта история. Если shell-обёртка нужна (например, надо подставить переменные окружения), спасает exec в конце скрипта: exec ./app "$@" заменяет процесс шелла приложением, и PID 1 становится тем, кем должен.

Как чинить правильно

  • Лучший вариант — обработать сигналы в приложении. Поймать SIGTERM, перестать принимать новые запросы, доработать текущие, закрыться. Это две строки почти на любом языке.
  • Если приложение чужое — подложить минимальный init. tini (в Docker включается флагом --init) или dumb-init: они занимают PID 1, пересылают сигналы дочернему процессу и жнут зомби. Весят десятки килобайт.
  • В Kubernetes при отладкеshareProcessNamespace: true в спеке пода. Тогда контейнеры пода видят процессы друг друга, PID 1 становится служебный pause-контейнер, и он же собирает зомби. Полезно и для отладки sidecar-ом с утилитами.

Спросят

«Почему docker stop висит десять секунд?» — Потому что процесс работает как PID 1, а ядро не применяет к PID 1 действие по умолчанию для SIGTERM. Приложение без явного обработчика сигнал игнорирует, Docker честно ждёт grace period и добивает SIGKILL. Часто виновата ещё и shell-форма CMD: PID 1 — это /bin/sh, который сигнал никому не передаёт. Лечится exec-формой, обработчиком в коде или --init/tini.

§8Потоки ядра — «процессы», которых на самом деле нет

В выводе ps много строк в квадратных скобках: [kworker/0:2], [ksoftirqd/1], [kswapd0], [jbd2/sda1-8]. Скобки означают, что у процесса нет командной строки, — это потоки ядра. Они живут целиком в пространстве ядра, не имеют пользовательской памяти и порождаются не от init, а от kthreadd (PID 2).

Знать их наизусть не нужно, но узнавать полезно, потому что они прямо указывают на проблему:

  • kswapd в топе по CPU — система активно свопит, кончается память;
  • ksoftirqd в топе — ядро захлёбывается программными прерываниями, обычно от сетевого трафика;
  • jbd2 — журнал ext4 упирается в диск;
  • kworker — общие фоновые задачи ядра, сами по себе ни о чём не говорят.

Убить их нельзя и не нужно: они не реагируют на сигналы, потому что не проходят через пользовательское пространство.

Заодно стоит проговорить разницу между процессом и потоком. Для планировщика Linux разницы почти нет: и то, и другое — задача (task) со своим идентификатором. Отличие в том, что́ они разделяют между собой: у процессов отдельные адресные пространства, у потоков одного процесса — общее. Оба создаются одним и тем же вызовом clone(), просто с разным набором флагов; fork() — частный случай clone(). Посмотреть потоки: ps -eLf или top -H.

§9Приоритеты, cgroups и OOM killer

Три вещи, которые определяют, кто получает ресурсы и кто умирает, когда их не хватает.

Nice. Значение от −20 (самый жадный) до +19 (самый уступчивый), по умолчанию 0. Влияет на долю процессорного времени, но не на память и не на диск. Повышать приоритет (уходить в минус) может только root. Запустить бэкап с низким приоритетом: nice -n 19 tar czf ..., а для диска отдельно есть ionice -c3.

Cgroups. Настоящий механизм ограничения ресурсов, на котором построены и контейнеры, и systemd. Ограничивают CPU, память, ввод-вывод, количество PID. Это то, во что превращаются resources.limits в манифесте пода: limits.cpu — в квоту на период планировщика, limits.memory — в жёсткий лимит памяти cgroup.

Ключевая разница между этими двумя лимитами, о которой обязательно спросят: превышение CPU-лимита приводит к троттлингу (процесс притормаживают, но он живёт), превышение лимита памяти — к немедленному убийству (OOM внутри cgroup, контейнер получает статус OOMKilled, код возврата 137 = 128 + 9). Память нельзя «выдать помедленнее», её можно только не дать.

Глобальный OOM killer просыпается, когда память кончается на всём узле. Он выбирает жертву по счёту oom_score, который считается в основном от объёма занятой памяти и корректируется значением oom_score_adj (от −1000 до +1000). Ирония в том, что жертвой обычно становится самый нужный процесс — потому что он самый большой. Отсюда практика защищать критичное:

# посмотреть, кого убьют первым
$ for p in /proc/[0-9]*; do
    echo "$(cat $p/oom_score) $(cat $p/comm)"
  done | sort -rn | head -5
871 java
430 postgres

# защитить (root)
$ echo -900 > /proc/1203/oom_score_adj

# кого уже убивали — в логах ядра
$ dmesg -T | grep -i "killed process"
[Fri Jul 11 03:14:22] Out of memory: Killed process 1203 (java)
                      total-vm:8421376kB, anon-rss:4102144kB

Грабли

Java до сих пор регулярно ловит OOMKilled в контейнере даже при, казалось бы, правильно выставленном -Xmx. Причина в том, что heap — не вся память процесса: сверху идут metaspace, стеки потоков, кодовый кеш JIT, прямые буферы NIO и внутренние аллокации JVM. Правило: limits.memory должен быть заметно больше -Xmx — или, лучше, вместо -Xmx использовать -XX:MaxRAMPercentage=75, чтобы JVM сама считала долю от лимита cgroup.

§10Как это звучит в ответе

«Имя файла — запись каталога, указывающая на inode; inode хранит метаданные и адреса данных, но не имя. Descriptor — номер открытого объекта в таблице конкретного процесса. Поэтому rm удаляет имя, но открытый файл продолжает занимать место до закрытия последнего FD. Если место есть, а файлы не создаются, проверяю df -i; если сервис пишет Too many open files — limits, /proc/PID/fd и lsof

«Все процессы в Linux рождаются из fork() — копии родителя — и обычно сразу делают exec(), заменяя себя нужной программой. Из этой пары следует всё остальное. Если ребёнок завершился, а родитель не забрал код возврата через wait(), остаётся зомби — запись в таблице процессов, которую нельзя убить сигналом, потому что убивать уже нечего; лечится через родителя. Обратная ситуация — сирота: родитель умер раньше, ребёнок продолжает работать и переходит под init. На этом же механизме построены демоны: двойной форк и setsid нужны, чтобы отвязаться от терминала, хотя сегодня это делает systemd.

По состояниям: R — на процессоре или в очереди, S — обычный сон в ожидании события, D — непрерываемый сон в ядре, из которого процесс не вытащить даже kill -9, потому что сигнал не доставят до конца системного вызова; устойчивое D — это всегда проблема с хранилищем. Z — зомби, T — остановлен.

Отдельно про контейнеры: там приложение работает как PID 1, а к PID 1 ядро не применяет действия по умолчанию для сигналов. Без явного обработчика SIGTERM игнорируется, и любая остановка превращается в SIGKILL через grace period. Плюс некому жать зомби. Поэтому — exec-форма ENTRYPOINT, обработка сигналов в коде и tini там, где приложение чужое.»