Inodes — метаданные файлов и жизнь без имени
В Unix-системах есть фундаментальное архитектурное решение, которое долго удивляет новичков: файл и его имя — разные вещи. Имя — это просто запись в директории, которая ссылается на inode. Один inode может иметь много имён (hard links). Имя можно удалить — inode останется жив, пока на него есть ссылки или процессы держат открытый файл. Это объясняет десятки практических ситуаций — от «удалил лог, а место не освободилось» до «программа продолжает писать в файл, который я удалил».
Inode (index node) — структура данных, описывающая всё про файл, КРОМЕ имени: размер, владелец, права, времена, и указатели на блоки данных. У каждого файла в ФС ровно один inode. Имя живёт в директории — запись «имя -> inode номер».
В этом уроке: что физически лежит в inode, как файл «живёт без имени», что такое inode exhaustion и почему он — реальный production-баг, и почему lsof | grep deleted спасает от потерянных гигабайт.
Что хранится в inode
В ext4 (типичная Linux-ФС) inode занимает 256 байт. В нём:
Обратите внимание: имени файла в inode НЕТ. Это ключевое решение Unix. Имя живёт в родительской директории, которая сама файл (типа directory) с записями «имя -> inode_number».
Посмотрим на реальный inode:
# Создадим файл и посмотрим его inode:
echo "Hello world" > /tmp/test.txt
stat /tmp/test.txt
# File: /tmp/test.txt
# Size: 12 Blocks: 8 IO Block: 4096 regular file
# Device: 802h/2050d Inode: 786432 Links: 1
# Access: (0644/-rw-r--r--) Uid: ( 1000/ myuser) Gid: ( 1000/ myuser)
# Access: 2026-05-18 10:00:00.123456789 +0000
# Modify: 2026-05-18 10:00:00.123456789 +0000
# Change: 2026-05-18 10:00:00.123456789 +0000
# Видно: inode 786432, size 12, links 1, права 644, owner myuser
Ключевые поля — именно те, что в inode. Имени /tmp/test.txt нет среди этих данных — оно живёт в записи директории /tmp.
Имя живёт в директории
Директория — это специальный тип файла, содержимое которого — таблица записей «имя -> inode_number».
# Создадим файлы и посмотрим директорию:
mkdir /tmp/demo
touch /tmp/demo/a.txt /tmp/demo/b.txt /tmp/demo/c.txt
# Показать inode номера:
ls -i /tmp/demo/
# 786433 a.txt 786434 b.txt 786435 c.txt
# Размер директории = сумма размеров записей:
stat /tmp/demo/
# Size: 4096 -- директория занимает один блок (минимум 4 КБ)
# В нём ~ 24-32 байта на запись
Запись в директории состоит из:
inode_number(4 байта в ext4).rec_len— длина записи (2 байта).name_len— длина имени (1 байт).file_type— тип файла (1 байт) — хранится здесь для оптимизации readdir.name— собственно имя, до 255 байт.
Когда вы делаете ls /tmp/demo, kernel читает блок директории, парсит записи, для каждого имени берёт inode, читает его, складывает в результат.
rm a.txt — это:
- Удалить запись «a.txt -> 786433» из директории.
- Уменьшить i_links_count в inode 786433 на 1.
- Если i_links_count = 0 и никто не держит fd — освободить inode + блоки данных.
Заметьте: на втором шаге, если кто-то держит файл открытым — inode жив, данные на месте. Это и есть «жизнь без имени».
Файл удалён, но процесс держит его открытым
Классический сценарий: огромный лог-файл, rm его, чтобы освободить диск — а место не освободилось. df показывает тот же used, du не находит большой файл. Что происходит?
# Демонстрация:
# Терминал 1: программа пишет в лог:
exec 9> /tmp/big.log
for i in {1..1000000}; do
echo "Line $i log entry" >&9
done &
LOGGER=$!
# Через секунду удаляем:
sleep 1
rm /tmp/big.log
ls /tmp/big.log
# ls: cannot access '/tmp/big.log': No such file or directory
# Но процесс продолжает писать!
# Disk usage не упал:
df /tmp
# Кто держит deleted-файлы:
lsof +L1 | grep deleted
# bash 12345 myuser 9w REG 8,2 123456789 786500 /tmp/big.log (deleted)
# ^^^ ^^^^^^^^^^^^^^^^
# write fd 9 помечен deleted
# Файл живёт пока fd 9 открыт. Чтобы освободить место:
# 1) Сказать процессу закрыть fd (kill / signal / API)
# 2) Или: восстановить файл через /proc:
ls -la /proc/12345/fd/9
# lr-x------ 1 myuser myuser 64 May 18 10:00 /proc/12345/fd/9 -> /tmp/big.log (deleted)
# Можно даже спасти данные:
cp /proc/12345/fd/9 /tmp/recovered.log
# Только когда процесс закроет fd (exit, kill, close()) -- место освободится
kill $LOGGER
lsof +L1 — стандартная команда для поиска deleted-but-still-open файлов. На production-сервере с jumbo логами это первый подозреваемый при нехватке места.
В production-сервисах часто пишут: `rm /var/log/app.log` -> диск не освободился -> поверили df но не lsof -> в панике рестартили сервис. Правильно: 1) запустить logrotate с copytruncate (truncate активный файл, не unlink), 2) или дать процессу сигнал на reopen логов (SIGHUP), 3) или использовать journald, который сам управляет ротацией.
Inode exhaustion — невидимая ловушка
В ext4 количество inode-ов фиксируется при создании ФС. Один inode на каждый файл. Если файлов много (хотя они маленькие) — можно исчерпать inode-ы при свободном дисковом пространстве.
# Использование disk space:
df -h /
# Filesystem Size Used Avail Use% Mounted on
# /dev/... 500G 100G 400G 20% /
# Использование inodes (отдельная статистика!):
df -i /
# Filesystem Inodes IUsed IFree IUse% Mounted on
# /dev/... 32000000 20000 31980000 1% /
# Если IUse% близко к 100% -- проблема!
# touch new_file -> "No space left on device"
# Хотя диск свободен на 80%
# Соотношение inodes/space задаётся при mkfs:
# По умолчанию ext4: ~1 inode на 16 КБ диска
# Можно явно: mkfs.ext4 -i 4096 (один inode на 4 КБ -- больше inodes)
Типичные сценарии inode exhaustion:
- mail spool с миллионами мелких писем.
- cache directory с миллиардами маленьких файлов (по одному на ключ).
- node_modules на dev-машине разработчика.
- Docker layer cache с тысячами слоёв.
# Найти, кто жрёт inodes:
sudo find / -xdev -type f 2>/dev/null | awk -F/ '{print $2"/"$3}' | sort | uniq -c | sort -rn | head -20
# 5234567 var/cache
# 1234567 home/user/project/node_modules
# ...
# Альтернатива -- du --inodes (если есть):
du --inodes -d 2 / 2>/dev/null | sort -rn | head -20
Лекарство: чистка, или пересоздание ФС с большим количеством inodes (mkfs.ext4 -N 128000000), или переход на XFS (динамически выделяет inodes).
Расширенные атрибуты (xattr)
inode хранит обычные поля, но также может ссылаться на extended attributes — произвольные key-value пары:
# Установить xattr:
setfattr -n user.author -v "myname" /tmp/test.txt
# Прочитать все xattr:
getfattr -d /tmp/test.txt
# file: tmp/test.txt
# user.author="myname"
# Системные xattr (требуют root):
# security.selinux -- SELinux label
# system.posix_acl_access -- POSIX ACL
# trusted.* -- root-only metadata
# Видеть в ls -l (флаг + после прав, например -rw-r--r--+):
getfacl /tmp/test.txt
Xattr используются:
- SELinux / AppArmor — security labels.
- ACL — расширенные права доступа.
- md5sum cache в некоторых тулзах.
- Docker — metadata о слоях.
Inode = ключевая абстракция
Если запомнить из VFS одну вещь — запомните inode. Это и есть «настоящий файл». Имя — ярлык. Path — последовательность ярлыков. Понимание этого сразу делает прозрачными:
- Hard links: одно содержимое, несколько имён. Все имена равноправны.
- Symbolic links: ссылка-указатель на имя (а не на inode), может быть битой.
- rm на открытом файле: убираем имя, inode жив.
- rename: меняется запись в директории, inode тот же.
- Move within filesystem: переименование между директориями, inode сохраняется (быстро).
- Move across filesystems: copy + remove, новый inode в новой ФС (медленно).
- df vs du: df смотрит на ФС (включая deleted-open), du — только видимые файлы.
Попробуй сам
Эксперименты с inode:
# Создать файл, посмотреть inode:
echo "data" > /tmp/file.txt
INODE=$(stat -c '%i' /tmp/file.txt)
echo "Inode: $INODE"
# Создать hard link:
ln /tmp/file.txt /tmp/link.txt
stat -c '%i Links: %h' /tmp/link.txt
# 786432 Links: 2 <-- тот же inode
# Удалить оригинал:
rm /tmp/file.txt
cat /tmp/link.txt
# data <-- по-прежнему доступно через link.txt!
# Удалить последнюю ссылку:
rm /tmp/link.txt
# Теперь inode 786432 действительно освобождён
# Хитрость: восстановление случайно удалённого файла
# (если процесс ещё держит его открытым)
python3 -c "
import time
f = open('/tmp/important.txt', 'w')
f.write('critical data\n')
f.flush()
print('PID:', __import__('os').getpid(), 'FD:', f.fileno())
time.sleep(60)
" &
PYPID=$!
sleep 1
# 'Случайно' удалили:
rm /tmp/important.txt
ls /tmp/important.txt # No such file
# Но процесс ещё держит! Lsof покажет:
lsof -p $PYPID | grep important
# Восстановить:
cp /proc/$PYPID/fd/3 /tmp/recovered.txt
cat /tmp/recovered.txt
# critical data
kill $PYPID
Демонстрация inode exhaustion:
# СОЗДАТЬ ТЕСТОВУЮ ФС (не на основной системе!):
dd if=/dev/zero of=/tmp/test_fs.img bs=1M count=100
mkfs.ext4 -N 1000 /tmp/test_fs.img # всего 1000 inodes
sudo mkdir -p /mnt/test
sudo mount /tmp/test_fs.img /mnt/test
df -i /mnt/test
# IUse только 11, IFree около 989
# Заполняем мелкими файлами:
sudo bash -c 'for i in {1..1000}; do touch /mnt/test/f$i; done' 2>&1 | tail -3
# touch: cannot touch ... No space left on device <-- inode exhaustion!
df -h /mnt/test # disk space почти свободен
df -i /mnt/test # inodes исчерпаны
# Cleanup:
sudo umount /mnt/test
rm /tmp/test_fs.img