Ошибка "Too many open files" посреди рабочей ночи, порт, занятый невидимым соседом, раздел, который не даёт себя размонтировать, и переполненный диск при скромных цифрах du. У всех этих историй один корень: какой-то процесс держит открытым то, что администратор считал давно закрытым. Утилита lsof отвечает на главный вопрос разбора: кто именно и что именно открыл. Ниже её вывод, фильтры и сценарии от подсчёта дескрипторов до поиска удалённых файлов, продолжающих занимать место на диске.
Открытый файл глазами lsof от каталога и библиотеки до сокета и канала
В Linux открытым файлом оказывается почти всё, до чего дотягивается процесс: обычный файл, каталог, загруженная библиотека, сетевой сокет, канал, символьное устройство. Ядро выдаёт каждому объекту числовой дескриптор, и именно эти дескрипторы показывает lsof. Утилита перечисляет открытые файлы, сокеты и каналы всех процессов, до которых дотягиваются её права, а без прав суперпользователя это процессы только текущего пользователя.
Запуск без аргументов выдаёт огромную простыню, поэтому первый навык, чтение колонок вывода:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
bash 2341 user cwd DIR 8,3 4096 12801 /home/user/project
python 2342 user 3r REG 8,3 1048576 28744 /home/user/data.csv
python 2342 user 4w REG 8,3 53248 28745 /tmp/report.tmp (deleted)
sshd 812 root 3u IPv4 15231 0t0 TCP *:ssh (LISTEN)
Колонка FD выглядит пугающе, но язык простой. Текущий каталог процесса обозначается cwd, корневой rtd, код и данные программы txt, отображённые в память библиотеки mem. Обычные дескрипторы это число с буквой доступа: r означает чтение, w запись, u чтение и запись одновременно.
TYPE уточняет природу объекта: REG обычный файл, DIR каталог, CHR и BLK символьное и блочное устройства, IPv4 и IPv6 сетевые сокеты, unix локальные сокеты, PIPE каналы. В колонке DEVICE живут старший и младший номера устройства, SIZE/OFF показывает размер файла либо текущую позицию, NODE хранит номер inode. NAME это путь для файлов, а для сокетов пара адресов с портами и состоянием в скобках. Суффикс (deleted) у имени, первый звоночек: файл уже убран с диска, но место под ним занято, пока дескриптор открыт.
Фильтры по процессу, пользователю и имени команды с объединением условий
Гонять lsof без фильтров имеет смысл один раз, чтобы оценить масштаб. Для работы нужны срезы. Ключ -p оставляет один процесс, -u собирает всё, что открыто от имени пользователя, -c выбирает процессы по началу имени команды:
lsof -p 4711
lsof -p 4711,4712
lsof -u www-data
lsof -c nginx
Пользовательский фильтр понимает списки и отрицания: lsof -u www-data,root покажет объекты двух пользователей, а lsof -u ^root всё, кроме root. Фильтр по имени команды ведёт себя шире, чем кажется: строка nginx цепляет и основной процесс, и его рабочие процессы, потому что сравнение идёт по началу имени.
Главная ловушка, логика объединения. По умолчанию несколько фильтров складываются как логическое ИЛИ: команда lsof -u www-data -p 4711 покажет и все файлы пользователя www-data, и все файлы процесса 4711, даже если процесс принадлежит кому-то другому. Для пересечения нужен ключ -a:
lsof -a -u www-data -p 4711
lsof -a -c nginx -u www-data
Отдельная группа фильтров работает с путями. Имя файла после имени утилиты отвечает на вопрос, кто открыл именно его: lsof /var/log/syslog. Ключ +d осматривает один уровень каталога, +D всё дерево целиком, но на больших деревьях +D ощутимо задумывается: утилита обходит каждый файл. Есть и фильтр по номерам дескрипторов: lsof -p 4711 -d 0-63 ограничит вывод диапазоном, а символ ^ в списке исключает служебные строки вроде txt и mem.
Сетевые соединения, состояние прослушивания и владелец занятого порта
Флаг -i переключает утилиту на сетевые объекты. Голый запуск lsof -i выводит все сетевые сокеты, а уточнения сужают выбор: -iTCP и -iUDP ограничивают протокол, -i4 и -i6 стек, -iTCP:22 порт, -iTCP:8000-8100 диапазон портов. Адрес хоста вписывается через символ @: lsof -i@10.0.0.5 покажет соединения, у которых он встречается на одном из концов. Состояние сокета фильтруется через -s, и классический запрос всех слушающих TCP-сокетов выглядит так:
lsof -nP -iTCP -sTCP:LISTEN
Знакомая ситуация: служба не стартует и пишет в журнал ошибку "Address already in use". Кто-то уже слушает порт, и кто именно, показывает одна строка с командой и процессом:
lsof -nP -iTCP:8080 -sTCP:LISTEN
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 4711 app 48u IPv4 15231 0t0 TCP *:8080 (LISTEN)
Без прав суперпользователя картина неполная: сокеты чужих процессов не показываются вовсе, и порт выглядит свободным, хотя занят. Есть и обратный нюанс: соединения в состоянии TIME_WAIT принадлежат ядру, а не процессу, поэтому lsof их не выводит. Когда нужен полный список состояний, включая TIME_WAIT, выручает ss:
ss -tanp
Преимущество lsof в том, что она сразу связывает сокет, дескриптор и команду, а ss заставляет сопоставлять номера процессов руками. Локальные сокеты собираются ключом -U, их число бывает показательно при поиске утечек межпроцессных каналов.
Ключи для скорости и машинного вывода, наблюдение с интервалом
На живой системе с тысячами соединений lsof без настроек подвисает, и дело не в объёме данных: утилита пытается превратить каждый адрес в имя хоста через обратные сетевые запросы и заменить номера портов именами служб из /etc/services. Каждый такой запрос, потенциальная пауза на нагруженном сервере. Четыре ключа экономят основную массу времени:
- -n отключает преобразование адресов в имена хостов и убирает паузы на сетевые запросы;
- -P оставляет номера портов числами вместо имён вроде http и ssh из /etc/services;
- -t сокращает вывод до идентификаторов процессов и удобен для подстановки в другие команды;
- -r с числом секунд повторяет опрос и превращает одиночный снимок в наблюдение за динамикой.
Наблюдение за конкретным процессом с интервалом 5 секунд запускается одной командой: lsof -nP -r 5 -p 4711. Каждый новый блок вывода, свежий снимок, и рост виден глазами, остановить наблюдение можно сочетанием Ctrl+C. Разновидность +r завершается сама, когда список опустел. Для сценариев на Shell предназначен машинный формат через -F: каждая строка вывода получает префикс из одной буквы, p для процесса, c для команды, n для имени объекта:
lsof -F pcn -p 4711
Такой поток проще разбирать awk, чем выровненные колонки с плавающей шириной. Связка -nP достойна статуса значения по умолчанию в каждом скрипте: она убирает зависимость вывода от состояния сети и делает результат повторяемым.
Ошибка Too many open files и подсчёт дескрипторов конкретного процесса
Сценарий утечки начинается с записи в журнале приложения: отказ в открытии файла или приёме нового соединения с пометкой EMFILE, errno номер 24. Мягкий лимит дескрипторов на процесс исчерпан, и ядро отказывает каждому новому open, socket и accept. Приложение при этом может выглядеть работоспособным: старые соединения обслуживаются, новые не принимаются, и со стороны это выглядит как зависания отдельных клиентов.
Лимиты процесса видны в procfs:
grep "open files" /proc/4711/limits
Max open files 1024 524288 files
Первое число мягкий лимит, второе жёсткий, конкретные значения зависят от дистрибутива и настроек службы. Для текущей оболочки те же цифры дают команды ulimit -n и ulimit -Hn. Дальше считаем фактическое число дескрипторов и сравниваем с лимитом:
ls /proc/4711/fd | wc -l
lsof -p 4711 | wc -l
Каталог fd в procfs, самый честный счётчик, а lsof добавляет к дескрипторам служебные строки вроде cwd, txt и mem, поэтому её число выходит чуть выше. Для диагностики важнее не абсолютная величина, а динамика:
watch -n 10 'ls /proc/4711/fd | wc -l'
Стабильные 120 дескрипторов при ровной нагрузке, норма. Монотонный рост между замерами, утечка. Теперь выясняем, что именно утекает. Разбивка по колонке TYPE сразу подсказывает класс проблемы:
lsof -p 4711 | awk '{print $5}' | sort | uniq -c | sort -rn | head
Тысячи строк IPv4 при десятке REG, сигнал, что приложение не закрывает соединения. Самый частый случай, состояние CLOSE_WAIT: удалённая сторона закрыла соединение, а локальный код так и не вызвал close, и дескриптор висит в таблице процесса. Быстрая проверка количества таких сокетов:
lsof -nP -p 4711 | grep -c CLOSE_WAIT
Исправление находится в коде приложения, но на время разбора лимит процессу поднимают. Для службы под systemd это параметр LimitNOFILE в юнит-файле, после правки нужны systemctl daemon-reload и перезапуск службы, а результат проверяется повторным чтением файла limits в procfs:
LimitNOFILE=8192
Файл /etc/security/limits.conf со строками soft nofile и hard nofile на службы под systemd не влияет: он работает для входящих сессий, и эта путаница дорого обходится при первой настройке. Сам подъём лимита лишь отодвигает проблему, настоящая утечка продолжит расти и упрётся в новый потолок через несколько дней вместо часов.
Удалённые файлы, которые держат место на диске через открытые дескрипторы
Вторая история начинается с противоречия: df -h /var показывает 97 процентов занятого места, а du -sh /var насчитывает едва ли половину, и перезапуск службы внезапно освобождает десятки гигабайт. Объяснение в механике удаления: unlink стирает запись в каталоге, но inode и блоки освобождаются только после закрытия последнего дескриптора. Пока пишущий процесс работает, ядро честно считает место занятым.
Утилита находит такие файлы по счётчику ссылок: у удалённого файла он падает ниже единицы, и ключ +L1 выводит именно их:
lsof +L1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
app 4711 appuser 4w REG 8,3 21474836480 45672 /var/log/app/requests.log (deleted)
Колонка SIZE/OFF показывает, сколько места держит каждый такой файл. Чаще всего это журнал, который ротировали переименованием, а службе после переименования не перечитали конфигурацию, либо временный файл, про который код забыл после сбоя. Если про +L1 вспомнить не удалось, тот же результат даёт связка lsof | grep deleted по суффиксу имени.
Решение зависит от задачи. Надёжное, перезапуск пишущей службы, после которого ядро освобождает блоки мгновенно. Быстрое, без остановки, обнуление файла через его дескриптор в procfs:
truncate -s 0 /proc/4711/fd/4
Приём срабатывает для обычных файлов и требует прав суперпользователя, а после него стоит сверить df. Профилактика тоже механическая: после ротации журналов служба обязана перечитать конфигурацию и открыть файлы заново, иначе каждый цикл ротации растит невидимый архив на диске.
Держатель каталога перед размонтированием и разумные границы утилиты
Третья история начинается с попытки размонтировать раздел: umount отвечает "target is busy", а что именно мешает, непонятно. Держателем бывает процесс записи с открытым файлом, редактор с несохранённым документом и даже забытая оболочка, для которой этот каталог текущий. Все они находятся через +D:
lsof +D /mnt/backup
lsof +d /mnt/backup
Ключ +D обходит дерево целиком и на больших каталогах работает медленно, +d смотрит один уровень. Альтернатива из пакета psmisc, fuser -vm /mnt/backup, печатает процессы и типы доступа к точке монтирования. После выяснения остаётся выбор: дождаться завершения записи, попросить службу освободить файлы или корректно остановить лишний процесс.
Теперь о границах. Без прав суперпользователя lsof показывает только процессы собственного пользователя, и чужие сокеты или файлы остаются невидимыми, на общем сервере это искажает картину до неузнаваемости. Без фильтров вывод на нагруженной системе измеряется десятками тысяч строк, поэтому рабочая привычка звучит просто: сначала процесс, пользователь, порт или каталог, потом запуск. Разрешение имён тормозит опрос, и связка -nP уместна в каждом скрипте. В контейнерах утилита видит процессы того пространства имён, в котором запущена, и полной картиной располагает только хост.
Собранные сценарии складываются в один порядок действий. Ошибка исчерпания дескрипторов начинается со сравнения ls /proc/PID/fd с лимитом и наблюдения за ростом, занятый порт сдаётся запросом lsof -nP -iTCP:PORT -sTCP:LISTEN, исчезнувшее место находится через +L1, упрямый раздел через +D. Утилита не заменяет ss, fuser и прямое чтение procfs, но связывает дескрипторы, процессы и имена в одну таблицу быстрее любого из них. Именно эта связка делает её первым инструментом, который открывают при разборе непонятного поведения сервера.