Раздел 2 · Linux — глава 2.1
Linux: процессы, inode и файловые дескрипторы
«Расскажите про типы процессов» — вопрос, на который легко ответить заученным списком «демоны, зомби, сироты» и не сказать ничего. На самом деле за этим стоит одна довольно изящная механика — то, как процессы вообще рождаются, — и все «типы» из неё следуют. Разберём её, а потом посмотрим, во что она превращается в контейнере.
§1Откуда вообще берутся процессы
В Linux нет системного вызова «запусти программу». Есть два отдельных вызова, и это ключ ко всему остальному.
fork() — клонирует текущий процесс. Был один — стало два, полностью одинаковых: тот же код, те же переменные, те же открытые файлы и сокеты. Отличаются они ровно одним: возвращаемым значением. Родителю fork() вернёт PID ребёнка, ребёнку — ноль. По этому нулю код и понимает, в какой из двух копий он сейчас исполняется.
exec() — заменяет содержимое процесса другой программой. PID остаётся тем же, открытые файловые дескрипторы (если не помечены как close-on-exec) остаются, а вот код, данные и стек затираются новой программой. Процесс не создаётся — процесс перерождается.
Запуск команды в шелле — это связка из двух: fork(), чтобы получить копию, и exec() в ребёнке, чтобы превратить копию в нужную программу. Родитель тем временем зовёт wait() и ждёт.
Копирование целого процесса ради того, чтобы через миллисекунду затереть его другой программой, звучит расточительно — и было бы им, если бы память копировалась по-настоящему. Но fork() использует copy-on-write: страницы памяти помечаются только для чтения и разделяются между родителем и ребёнком, а физическое копирование происходит лениво, только для тех страниц, в которые кто-то записал. Поэтому fork() дёшев.
Грабли
Copy-on-write перестаёт быть бесплатным, когда родитель занимает много памяти и активно в неё пишет. Классика — Redis, который делает fork() для сохранения снапшота на диск: пока идёт запись RDB, каждая изменённая клиентом страница копируется, и потребление памяти может вырасти почти вдвое. Отсюда рекомендация держать под Redis запас RAM и включать vm.overcommit_memory=1.
§1.1Inode и файловые дескрипторы
Эти понятия часто спрашивают вместе, хотя они живут на разных уровнях. Inode описывает объект файловой системы, а file descriptor описывает доступ конкретного процесса к открытому объекту ядра.
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 link | Symlink | |
|---|---|---|
| Что хранит | Ещё одно имя того же 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. Данные освобождаются только когда одновременно выполнены два условия:
- жёстких ссылок больше нет: link count равен нулю;
- не осталось открытых 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, и понимать их — половина навыка диагностики.
| Код | Состояние | Что это на самом деле значит |
|---|---|---|
R | Running / Runnable | Либо прямо сейчас на процессоре, либо стоит в очереди и готов исполняться. Много R при высоком load average — упёрлись в CPU. |
S | Interruptible sleep | Ждёт события: данных из сокета, таймера, ввода. Прерывается сигналом. Абсолютное большинство процессов в системе — здесь, и это норма. |
D | Uninterruptible sleep | Ждёт завершения операции ввода-вывода в ядре и не реагирует ни на что, включая kill -9. Про это отдельный разговор ниже. |
T | Stopped | Приостановлен сигналом SIGSTOP/SIGTSTP (Ctrl+Z) или отладчиком. Оживает по SIGCONT. |
Z | Zombie | Уже завершился, но родитель не забрал код возврата. Занимает только строчку в таблице процессов. |
I | Idle | Простаивающий поток ядра. Введён отдельно, чтобы такие потоки не накручивали 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Демоны: как процесс отвязывается от терминала
Демон — это просто фоновый процесс, который не связан ни с каким терминалом и работает всё время жизни системы. Классический способ им стать («двойной форк») сейчас в основном исторический, но понимать его полезно, потому что он объясняет, зачем вообще нужны сессии и группы процессов.
fork(), родитель немедленно выходит. Ребёнок становится сиротой, его усыновляет init — теперь он гарантированно не лидер группы процессов.setsid()— создаёт новую сессию и новую группу процессов, отвязываясь от управляющего терминала. ТеперьSIGHUPпри закрытии терминала до него не долетит.- Второй
fork()— чтобы процесс перестал быть лидером сессии и уже никогда не смог случайно захватить терминал. chdir("/")— чтобы не держать занятой какую-нибудь файловую систему и не мешать её размонтированию.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 там, где приложение чужое.»