Перейти к содержимому

Файловая система: inode, дескрипторы, ссылки, права

Модель, которую надо держать в голове, состоит из трёх уровней. Внизу — блочное устройство и конкретная файловая система (ext4, XFS, Btrfs, tmpfs, overlayfs), которая раскладывает по блокам суперблок, таблицу инодов и данные. В середине — VFS (Virtual File System), общий слой ядра, который прячет различия конкретных ФС за четырьмя объектами: super_block (смонтированная ФС), inode (файл как сущность с метаданными), dentry (элемент каталога — связка «имя → inode», живёт в dcache) и file (открытый файл, он же open file description). Сверху — процесс, у которого есть таблица дескрипторов: массив, где индекс — это число, которое видит программа, а значение — указатель на struct file.

Ключевая идея всей темы: имя файла и сам файл — разные вещи. Файл — это inode: тип, права, uid/gid, размер, времена, счётчик жёстких ссылок и указатели на блоки данных. Имени в inode нет. Имя живёт в каталоге, который сам является файлом особого типа и содержит записи «строка → номер inode». Отсюда напрямую следуют почти все вопросы подтемы: почему жёсткая ссылка — это просто ещё одна запись в каталоге; почему удаление называется unlink и не всегда освобождает место; почему право на удаление файла проверяется по каталогу, а не по самому файлу; почему mv внутри одной ФС мгновенный, а между ФС — это копирование.

Вторая идея — про уровни косвенности при открытии. Дескриптор (fd) — это индекс в таблице процесса. Несколько разных fd (в том числе в разных процессах после fork) могут указывать на одну и ту же open file description, у которой свои позиция чтения (f_pos), флаги доступа и O_APPEND. И уже описание указывает на inode. Поэтому dup2 даёт общий указатель позиции, а два независимых open() того же файла — раздельные позиции. Пока хотя бы одно описание ссылается на inode, ядро не освободит его блоки, даже если из всех каталогов имя удалено — это и есть механика «файл удалён, а место не вернулось».

Третья идея — целостность. Запись файла — это несколько независимых изменений на диске (битовая карта блоков, inode, запись каталога, сами данные). Отключение питания посередине оставляет ФС в противоречивом состоянии. Журнал (write-ahead log) решает это: сначала намерение пишется в журнал и коммитится, потом изменения применяются на месте; после сбоя достаточно проиграть журнал за секунды вместо многочасового fsck. Journaling защищает согласованность метаданных, но не гарантирует, что ваши данные попали на диск — за это отвечает fsync.

Коротко. Файловый дескриптор — неотрицательное целое, индекс в таблице открытых файлов процесса. Ограничений три уровня: RLIMIT_NOFILE на процесс (soft/hard, ulimit -n), системный потолок на процесс /proc/sys/fs/nr_open и общесистемный /proc/sys/fs/file-max. При исчерпании процессного лимита системные вызовы возвращают EMFILE, при исчерпании общесистемного — ENFILE.

Глубже. Soft-лимит можно поднять самому процессу до hard-лимита через setrlimit(2), hard-лимит поднимает только CAP_SYS_RESOURCE. В systemd-дистрибутивах лимиты задаются LimitNOFILE= в юните и DefaultLimitNOFILE= в system.conf; исторически soft по умолчанию 1024 (это сделано ради совместимости со старым кодом на select()), hard — сотни тысяч. Именно select(2) — отдельное ограничение: он работает с битовой маской фиксированного размера FD_SETSIZE = 1024, и дескриптор ≥ 1024 в него просто не помещается (undefined behaviour), поэтому серверы используют poll/epoll. Дескрипторы всегда выделяются как наименьший свободный номер — на этом основан классический трюк close(0); open(...).

Практика диагностики: cat /proc/<pid>/limits, ls /proc/<pid>/fd | wc -l, lsof -p <pid>, счётчики в /proc/sys/fs/file-nr. Отдельно живут лимиты на inotify (fs.inotify.max_user_watches, max_user_instances) и на память сокетов — их часто путают с лимитом fd. Для Go важно: начиная с Go 1.19 рантайм при старте сам поднимает soft RLIMIT_NOFILE до hard-значения, а os/exec возвращает исходный soft-лимит дочерним процессам — то есть Go-программе обычно не нужен ручной ulimit -n, но запускаемым из неё сишным утилитам старый лимит достанется.

var lim syscall.Rlimit
_ = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim)
fmt.Println(lim.Cur, lim.Max) // soft, hard
lim.Cur = lim.Max
_ = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim)

Что такое файловый дескриптор и для чего используется?

Заголовок раздела «Что такое файловый дескриптор и для чего используется?»

Коротко. Это небольшое неотрицательное целое — индекс в таблице открытых файлов процесса, по которому ядро находит struct file. Через дескриптор работают все операции ввода-вывода: read, write, close, lseek, mmap, ioctl, epoll_ctl. Он нужен, чтобы дать процессу непрозрачный хендл на ресурс ядра, не выдавая наружу указателей.

Глубже. Дескриптор — это не только файл на диске. В Unix «всё есть файл»: сокеты, пайпы, терминалы, устройства, eventfd, timerfd, signalfd, inotify, epoll, memfd, pidfd — всё это дескрипторы, и их можно единообразно скармливать epoll. Дескрипторы наследуются при fork (копируется таблица, описания общие) и переживают exec, если не выставлен флаг FD_CLOEXEC; современный код всегда открывает с O_CLOEXEC, чтобы не утечь дескриптором в дочерний процесс. Дескрипторы можно передавать между процессами через unix-сокет (SCM_RIGHTS) — на этом построены systemd socket activation и graceful restart без потери слушающего сокета.

В Go дескриптор спрятан за *os.File; достать его можно через f.Fd() (осторожно: перевод в блокирующий режим и выход из-под netpoller) или корректнее через f.SyscallConn().Control(...). Обратно: os.NewFile(fd, name). Утечка дескрипторов в Go — это забытые resp.Body.Close() и defer f.Close() внутри цикла.

Заголовок раздела «В чем разница между символической ссылкой (soft link) и жесткой ссылкой (hard link)?»

Коротко. Жёсткая ссылка — это просто ещё одно имя того же inode в каталоге; у файла нет «оригинала» и «копии», все жёсткие ссылки равноправны, а inode хранит их счётчик nlink. Символическая ссылка — отдельный файл особого типа, содержимое которого — строка с путём; она разыменовывается при обращении.

Глубже. Практические различия: жёсткая ссылка не может пересекать границу файловой системы (номер inode уникален только внутри ФС) и не может указывать на каталог (запрет ядра, чтобы не создавать циклы в дереве; исключение — служебные . и ..). Символическая может и то, и другое, может указывать на несуществующий путь (dangling), может быть относительной. Symlink имеет собственный inode и собственные времена; в ext4 «быстрая» ссылка длиной до 59 байт хранится прямо в поле блочных указателей inode и не занимает отдельного блока. Права у symlink на Linux не проверяются и всегда показываются как lrwxrwxrwx — доступ решается правами цели.

Отсюда набор системных вызовов «с разыменованием и без»: stat/lstat, chown/lchown, open(O_NOFOLLOW), readlink, realpath. В Go то же самое: os.Stat идёт по ссылке, os.Lstat — нет; os.Readlink, filepath.EvalSymlinks. Создание: os.Link(old, new) — жёсткая, os.Symlink(old, new) — символическая.

Окно терминала
$ ln file.txt hard.txt # nlink станет 2, тот же inode
$ ln -s file.txt soft.txt # отдельный inode типа symlink
$ ls -li
131074 -rw-r--r-- 2 user user 5 Aug 11 12:00 file.txt
131074 -rw-r--r-- 2 user user 5 Aug 11 12:00 hard.txt
131075 lrwxrwxrwx 1 user user 8 Aug 11 12:00 soft.txt -> file.txt

Коротко. Чтобы файловая система переживала внезапную потерю питания или падение ядра без потери целостности метаданных и без полной проверки диска. Изменения сначала атомарно записываются в журнал (write-ahead log), и после сбоя ядро при монтировании просто проигрывает или откатывает незакоммиченные транзакции за секунды вместо многочасового fsck.

Глубже. Одна логическая операция (например, создание файла) состоит из нескольких физических записей: битовая карта инодов, битовая карта блоков, сам inode, запись в каталоге, данные. Без журнала сбой посередине даёт «потерянные» иноды, блоки, помеченные и занятыми и свободными, ссылки в никуда. Журнал группирует эти записи в транзакцию: write записи в журнал → барьер → commit-запись → checkpoint (применение на месте). В ext3/ext4 этим занимается подсистема jbd2, а режим задаётся опцией монтирования data=:

  • data=ordered (по умолчанию в ext4) — журналируются только метаданные, но блоки данных гарантированно попадают на диск до коммита метаданных; это исключает ситуацию, когда новый inode ссылается на блоки со старым мусором.
  • data=journal — в журнал пишутся и данные тоже: максимальная надёжность, двойная запись, вдвое ниже пропускная способность.
  • data=writeback — журналируются только метаданные, порядок данных не гарантируется: быстро, но после сбоя в новом файле может оказаться содержимое чужих старых блоков.

XFS журналирует только метаданные (аналог ordered/writeback), Btrfs и ZFS вместо классического журнала используют copy-on-write и атомарную смену корня дерева (у ZFS есть ZIL для синхронных записей). Важно, что журнал защищает согласованность, а не сохранность вашей записи: без fsync/fdatasync данные могут остаться в page cache и пропасть. Полезные команды: dumpe2fs -h /dev/sda1 (в том числе Journal features), tune2fs -O ^has_journal (выключить журнал, например на ФС поверх NAND с батарейкой), mount -o data=writeback.

На диске 100 ГБ, файл занимает 80 ГБ. После его удаления свободное место не увеличилось. Как диагностировать проблему?

Заголовок раздела «На диске 100 ГБ, файл занимает 80 ГБ. После его удаления свободное место не увеличилось. Как диагностировать проблему?»

Коротко. В 9 случаях из 10 файл кто-то держит открытым: unlink убрал имя из каталога, но inode с nlink=0 и его блоки живут, пока есть открытые дескрипторы. Диагностика — lsof +L1 или lsof -nP | grep deleted; лечение — перезапустить/перезагрузить держащий процесс либо обнулить файл через /proc/<pid>/fd/<n>.

Глубже. Симптом легко узнать по расхождению: df показывает занятое место, а du -sh по тому же каталогу — нет; du ходит по именам, а безымянного inode в дереве уже нет. Типичный виновник — приложение, которому логи ротировали через rm/mv без copytruncate и без сигнала на переоткрытие.

Окно терминала
$ lsof +L1 # файлы с link count < 1
$ lsof -nP | awk '/deleted/{print}'
$ ls -l /proc/12345/fd | grep deleted
$ : > /proc/12345/fd/7 # освободить место, не убивая процесс

Если удалённых открытых файлов нет, проверяем остальное по списку:

  • Смотрим не на ту ФС: df -h /path вместо df -h, findmnt -T /path. Каталог может быть точкой монтирования другой ФС или, наоборот, файл лежит «под» примонтированной сверху ФС и виден только после mount --bind / /mnt.
  • Резерв суперпользователя: ext4 по умолчанию резервирует 5% (tune2fs -l | grep 'Reserved block'), обычному пользователю они недоступны; tune2fs -m 1 /dev/sdX.
  • Снапшоты и рефлинки: LVM/Btrfs/ZFS-снапшот держит старые экстенты, удаление из «живой» ФС места не даёт. btrfs subvolume list, zfs list -t snapshot.
  • Разреженный (sparse) файл или наоборот файл с дырами: du --apparent-size против du, stat (поле Blocks).
  • Thin-provisioned том или дискарды: место освобождено в ФС, но не отдано хранилищу — нужен fstrim -av.
  • В контейнерах — слои overlayfs: файл удалён в верхнем слое (whiteout), но занят в нижнем image-слое.

Коротко. os.Chmod(name string, mode fs.FileMode) error — обёртка над chmod(2): меняет права уже существующего файла, следуя по символическим ссылкам. Есть метод (*os.File).Chmod (через fchmod). Права меняет только владелец файла или процесс с CAP_FOWNER, иначе EPERM.

Глубже. Три подводных камня, которые обычно и проверяют.

Первое: fs.FileMode в Go — это не режим POSIX. Младшие 9 бит совпадают с rwxrwxrwx, но тип файла и специальные биты живут в старших битах и имеют свои константы: os.ModeSetuid, os.ModeSetgid, os.ModeSticky, os.ModeDir, os.ModeSymlink. Рантайм переводит их в S_ISUID/S_ISGID/S_ISVTX при вызове (см. syscallMode в src/os/file_posix.go). То есть setuid ставится как 0755|os.ModeSetuid, а не как 04755.

Второе: chmod не подчиняется umask — umask применяется только при создании (os.OpenFile, os.Mkdir, os.WriteFile). Классический баг: os.WriteFile(path, data, 0666) при umask 022 даёт файл 0644, а os.Mkdir(dir, 0777) — каталог 0755. Если нужны точные права — вызывайте os.Chmod после создания (или f.Chmod, чтобы не было гонки по имени). И ещё более классический баг — писать os.Chmod(p, 644): без ведущего нуля это десятичная 644 = 01204₈, то есть --w----r-- плюс setgid.

Третье: portability и symlink. На Windows учитывается фактически только бит 0200 (read-only). Симлинк как таковой в Go «чмодить» нечем — lchmod в stdlib нет, да и на Linux права симлинка ничего не значат. Для защиты от подмены пути (TOCTOU/симлинк-атаки) в Go 1.24 появился os.Root — доступ, ограниченный поддеревом каталога; набор его методов расширялся в последующих релизах, Root.Chmod добавился уже после 1.24, так что в 1.24 надёжный путь — os.OpenFile(..., O_NOFOLLOW) и затем f.Chmod.

f, err := os.OpenFile("secret.key", os.O_CREATE|os.O_WRONLY|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return err
}
defer f.Close()
if err := f.Chmod(0o600); err != nil { // гарантируем права независимо от umask
return err
}

Коротко. См. выше про файловый дескриптор: неотрицательное целое, индекс в таблице открытых файлов процесса, через который выполняются все операции ввода-вывода. Отличие формулировки в акценте на трёхуровневую схему: таблица дескрипторов процесса → open file description (struct file: позиция, флаги) → inode.

Глубже. Именно средний уровень объясняет неочевидные вещи. dup/dup2 создают новый дескриптор, указывающий на то же описание: общая позиция, общие флаги O_APPEND/O_NONBLOCK; закрытие одного из них не влияет на другой. fork копирует таблицу дескрипторов, но описания остаются общими — поэтому родитель и потомок, пишущие в один и тот же дескриптор, двигают одну позицию. А два независимых open() одного файла дают два разных описания с независимыми позициями. Флаг FD_CLOEXEC принадлежит записи в таблице дескрипторов (то есть конкретному fd), а O_APPEND/O_NONBLOCK — описанию; это любимый уточняющий вопрос.

Посмотреть всё это можно так: ls -l /proc/<pid>/fd (симлинки на цели), cat /proc/<pid>/fdinfo/<n> (там pos, flags, mnt_id) — два fd, ссылающихся на одно описание, имеют одинаковый pos, который меняется синхронно.

Что такое stdin/stdout/stderr, какие дескрипторы назначены для этих сущностей?

Заголовок раздела «Что такое stdin/stdout/stderr, какие дескрипторы назначены для этих сущностей?»

Коротко. Это три стандартных потока, которые процесс получает от родителя уже открытыми: stdin — дескриптор 0 (ввод), stdout1 (обычный вывод), stderr2 (ошибки и диагностика). Константы в C — STDIN_FILENO/STDOUT_FILENO/STDERR_FILENO, в Go — os.Stdin, os.Stdout, os.Stderr.

Глубже. Ничего магического в них нет: это обычные дескрипторы, просто по соглашению занимающие младшие номера, а «назначает» их оболочка, делая dup2 перед exec. Отсюда синтаксис перенаправлений: > = 1>, 2> — только ошибки, 2>&1 — сделать 2 копией описания, на которое смотрит 1 (порядок важен: cmd > f 2>&1 перенаправит оба в файл, а cmd 2>&1 > f — только stdout в файл, stderr останется на терминале), &> (bash) — оба сразу, < — подставить stdin. Смысл разделения 1 и 2 — чтобы в пайплайне a | b в b шли только данные, а диагностика по-прежнему попадала пользователю на терминал.

Важная деталь для практики: буферизация в libc зависит от того, терминал ли на другом конце — stdout при выводе в терминал буферизуется построчно, а в пайп/файл — блоками по 4–8 КБ, поэтому логи «пропадают» при падении; stderr не буферизуется. В Go этой проблемы нет: os.Stdout — небуферизованный *os.File, каждый fmt.Println идёт прямым write(2) (что, наоборот, делает вывод в цикле медленным — оборачивайте в bufio.Writer и не забывайте Flush). Демоны, которые «отвязываются» от терминала, переоткрывают все три на /dev/null, иначе первая же запись даст EBADF или SIGPIPE.

du/dh показывает, что место в файловой системе еще есть, а записать ничего не возможно, т.к. “нет места”. Почему и как исправить?

Заголовок раздела «du/dh показывает, что место в файловой системе еще есть, а записать ничего не возможно, т.к. “нет места”. Почему и как исправить?»

Коротко. Самая частая причина — кончились иноды, а не блоки: df -i покажет IUse% 100%. Второй кандидат — резерв под root (5% в ext4), из-за которого обычный пользователь получает ENOSPC раньше, чем место реально кончится. Третий — расхождение du и df из-за удалённых, но открытых файлов.

Глубже. Полный чеклист диагностики:

Окно терминала
$ df -h /path && df -i /path # блоки и иноды по нужной ФС
$ findmnt -T /path # точно ли это та ФС, что мы думаем
$ tune2fs -l /dev/sdX | grep -i reserved
$ lsof +L1 # удалённые, но открытые
$ dmesg -T | tail # ошибки ФС, remount-ro, I/O errors
  • Иноды кончились. Классика: миллионы мелких файлов в кеше, почтовой очереди, /var/lib/php/sessions. Число инодов в ext4 фиксируется при mkfs (bytes-per-inode) и на смонтированной ФС не увеличивается — либо удалять мелочь (find /var/spool -xdev -type f -delete), либо пересоздавать ФС с mkfs.ext4 -i 8192/-N. В XFS и Btrfs иноды выделяются динамически, так что там этой проблемы обычно нет.
  • Резервные блоки. tune2fs -m 1 /dev/sdX уменьшит резерв с 5% до 1% и мгновенно вернёт место обычным пользователям (на корневой ФС не стоит уводить в 0 — резерв нужен, чтобы демоны root могли писать при заполнении).
  • Удалённые открытые файлы. См. предыдущий вопрос: lsof +L1, : > /proc/<pid>/fd/<n>, рестарт процесса.
  • ФС перемонтирована в read-only после ошибки — тогда ошибка будет EROFS, но её часто путают с «нет места»; смотрим dmesg и mount | grep ' ro,'.
  • Квоты: repquota -a, quota -u user (ошибка будет EDQUOT, «Disk quota exceeded»).
  • Фрагментация свободного места (нужен непрерывный экстент) и специфичное для ext4 переполнение хеш-дерева каталога: при десятках миллионов имён в одном каталоге ядро возвращает ENOSPC с сообщением ext4: Directory index full — лечится включением large_dir (ядро 4.13+) или разбиением на подкаталоги.
  • Особые ФС: tmpfs ограничена size=, overlayfs упирается в нижележащую ФС, в контейнере проверять надо ФС хоста.

Коротко. Обе — псевдофайловые системы в памяти: они не хранят данные на диске, а генерируют содержимое файлов на лету при чтении. procfs (/proc) — интерфейс к процессам и к настройкам ядра: /proc/<pid>/* и /proc/sys/*. sysfs (/sys) — структурированный экспорт внутренней модели объектов ядра (kobject): устройства, драйверы, шины, классы, блочные устройства.

Глубже. В /proc на процесс приходится каталог с status, cmdline, environ, limits, maps/smaps, stat, io, fd/, task/ — на этом построены ps, top, lsof, pmap. Глобальные файлы — /proc/meminfo, /proc/cpuinfo, /proc/mounts, /proc/net/*, /proc/loadavg, /proc/interrupts. Ветка /proc/sys — это интерфейс sysctl: sysctl net.ipv4.tcp_tw_reuse и cat /proc/sys/net/ipv4/tcp_tw_reuse — одно и то же, а /etc/sysctl.d/*.conf фиксирует значения между перезагрузками. Файлы в /proc почти всегда имеют размер 0 и не поддерживают seek как обычные файлы; читать их надо целиком. Полезная опция монтирования — hidepid=2, скрывающая чужие процессы.

sysfs появился, чтобы разгрузить /proc от «всего подряд», и подчиняется правилу «один файл — одно значение»: /sys/block/sda/queue/scheduler, /sys/class/net/eth0/statistics/rx_bytes, /sys/devices/... с симлинками из /sys/class и /sys/bus. На нём работает udev: события об устройствах приходят через uevent, а атрибуты читаются из sysfs. Рядом живут родственники: cgroupfs в /sys/fs/cgroup (лимиты CPU/памяти, основа контейнеров), debugfs в /sys/kernel/debug, tracefs, securityfs, а также devtmpfs в /dev. Практический вывод для собеседования: /proc — «про процессы и sysctl», /sys — «про железо, драйверы и cgroups», и то и другое — чистый интерфейс ядра, а не файлы на диске.

Как устроена в общих чертах файловая система в Linux? Что такое inode?

Заголовок раздела «Как устроена в общих чертах файловая система в Linux? Что такое inode?»

Коротко. Единое дерево от /, в которое монтируются конкретные ФС; доступ к любой из них идёт через общий слой VFS с объектами super_block, inode, dentry, file. На диске ФС состоит из суперблока (параметры), таблиц/битовых карт инодов и блоков и области данных. Inode — структура с метаданными файла: тип, права, uid/gid, размер, времена, счётчик жёстких ссылок и указатели на блоки данных. Имени файла в inode нет — имя хранится в каталоге как запись «имя → номер inode».

Глубже. Каталог — это файл особого типа, содержащий эти записи (в ext4 — линейный список или хеш-дерево htree; в XFS — B+-дерево). Поиск пути /var/log/syslog — это последовательность: inode корня → найти в его содержимом var → inode var → найти log → и так далее; чтобы это не стоило дисковых чтений, ядро кеширует результат в dcache (dentry), а сами данные — в page cache. ext4 хранит блоки не списком указателей, а деревом экстентов (диапазонов), что резко экономит метаданные для больших файлов; блок по умолчанию 4 КБ, ФС разбита на block groups, каждая со своей битовой картой и куском таблицы инодов — ради локальности. Времена в inode: atime (доступ, обычно relatime, чтобы не писать при каждом чтении), mtime (изменение содержимого), ctime (изменение самого inode — прав, владельца, ссылок); ctime нельзя подделать через touch, времени создания в POSIX нет, но ext4 хранит crtime (видно через stat -c %W / debugfs).

Номер inode уникален внутри одной ФС — поэтому файл однозначно определяется парой (устройство, inode), и поэтому же жёсткие ссылки не пересекают ФС. Практика: stat file, ls -i, df -i, find . -inum N, debugfs -R "stat <12345>" /dev/sda1. Из соседних сущностей стоит помнить: монтирование в произвольную точку дерева, bind-mounts, mount namespaces (основа контейнеров), overlayfs (объединение нижних read-only слоёв и верхнего writable с whiteout-файлами для удалений), tmpfs целиком в памяти, а также специальные типы inode — каталог, симлинк, FIFO, сокет, блочное и символьное устройство.

Окно терминала
$ stat /etc/hosts
File: /etc/hosts
Size: 221 Blocks: 8 IO Block: 4096 regular file
Device: 8,1 Inode: 262146 Links: 1
Access: (0644/-rw-r--r--) Uid: (0/root) Gid: (0/root)
Заголовок раздела «Чем отличается hard link от symbolic link? Что будет с файлом, если удалить hard link? symlink?»

Коротко. Отличия — см. выше про soft/hard link. По удалению: rm любой жёсткой ссылки уменьшает nlink на единицу и удаляет лишь имя; данные освобождаются, только когда nlink дошёл до нуля и файл никем не открыт. Удаление символической ссылки удаляет только саму ссылку, целевой файл не затрагивается; а вот удаление цели оставляет «висячий» (dangling) симлинк, который перестаёт открываться с ENOENT.

Глубже. Отсюда и название системного вызова — unlink(2), а не «delete»: ядро отвязывает имя, а сборка мусора происходит по двум счётчикам — i_nlink (ссылки из каталогов) и i_count/число открытых описаний. Это и есть механика «удалил большой файл, а df не изменился»: nlink=0, но открытый дескриптор держит inode. Побочный полезный эффект — идиома «безымянного временного файла»: создать, сразу unlink, продолжать писать в дескриптор; файл гарантированно исчезнет при завершении процесса (современный аналог — open(O_TMPFILE)).

Ещё практические следствия. rm -r по симлинку на каталог удалит саму ссылку, а не содержимое каталога (а rm -r symlink/ со слэшем — уже спорное поведение, зависит от реализации; так делать не надо). cp по умолчанию идёт по симлинку и копирует данные, cp -a/-P сохраняет ссылку как ссылку. tar по умолчанию сохраняет симлинки и умеет сохранять жёсткие ссылки как ссылки. Жёсткие ссылки ломают наивные бэкапы и подсчёт размера: du считает inode один раз, du -l — каждый раз. Наконец, rsync --link-dest и hardlink-дедупликация в бэкапах опасны тем, что запись «в копию» через ссылку меняет один и тот же inode для всех имён.

Что можете рассказать о file permissions в Linux? (chmod, suid, sgid, sticky bit, ACL, etc.)

Заголовок раздела «Что можете рассказать о file permissions в Linux? (chmod, suid, sgid, sticky bit, ACL, etc.)»

Коротко. Базовая модель — три класса (владелец / группа / остальные) по три бита (r, w, x), плюс три специальных бита (setuid, setgid, sticky) — итого четыре восьмеричные цифры. При обращении ядро выбирает ровно один класс: если uid совпал — применяются права владельца (даже если группа даёт больше), иначе если совпала группа — групповые, иначе «остальные». Сверху этого — POSIX ACL для точечных исключений, capabilities вместо setuid-root, атрибуты chattr и LSM (SELinux/AppArmor).

Глубже. Для каталогов биты означают не то же, что для файлов: r — можно прочитать список имён, x — можно «пройти» через каталог и обратиться к его записям по имени (получить inode), w+x — можно создавать, удалять и переименовывать записи. Каталог --x даёт доступ к известным путям без возможности листинга — так делают /home/user при chmod 711.

Специальные биты:

  • setuid (4000) на исполняемом файле — процесс получает euid владельца файла (/usr/bin/passwd работает от root). На Linux игнорируется для скриптов (шебанг) и при монтировании с nosuid. Современная замена — file capabilities: setcap cap_net_bind_service=+ep ./server вместо suid-root.
  • setgid (2000) на файле — egid владельца-группы; на каталоге — совсем другое: новые файлы наследуют группу каталога, а новые подкаталоги — сам бит setgid. Это стандартный способ организации общей папки для команды.
  • sticky (1000) на каталоге — «restricted deletion»: удалять и переименовывать записи может только владелец файла, владелец каталога или root. Именно поэтому /tmp имеет режим 1777 (drwxrwxrwt) и никто не удаляет чужие файлы. На обычных файлах в Linux бит игнорируется.

umask (обычно 022) вычитает биты при создании: файлы 0666 & ~umask = 0644, каталоги 0777 & ~umask = 0755; x у обычных файлов никогда не появляется автоматически. chmod принимает как восьмеричную форму (chmod 750 f), так и символьную (chmod u+x,g-w,o= f, chmod -R a+rX dir — заглавная X ставит x только каталогам и уже исполняемым файлам). Владельца меняет только root: chown/chgrp.

POSIX ACL нужны, когда трёх классов мало: setfacl -m u:alice:rw file, getfacl file; наличие ACL видно по + в выводе ls -l. Ключевая тонкость — mask: эффективные права named-пользователей и групп ограничиваются маской, а chmod g=... эту маску переписывает, из-за чего ACL «неожиданно» урезаются. На каталогах бывают default-ACL (setfacl -d -m ...), которые наследуются новыми файлами.

Дополнительные слои: chattr +i (immutable — файл не изменить и не удалить даже root, пока не снят бит), +a (только дозапись, удобно для логов); lsattr для просмотра. Опции монтирования ro, noexec, nosuid, nodev перекрывают всё вышеперечисленное. Наконец, root обходит проверки DAC благодаря CAP_DAC_OVERRIDECAP_DAC_READ_SEARCH, CAP_FOWNER) — а SELinux/AppArmor проверяются после DAC и могут запретить то, что права разрешили.

Коротко. Ведущий 0 — это признак восьмеричного числа (специальные биты нулевые), дальше по цифре на класс: 7 = rwx владельцу, 6 = rw- группе, 4 = r— остальным. В выводе ls -l это -rwxrw-r--.

Глубже. Расшифровка по битам: r=4, w=2, x=1, поэтому 7 = 4+2+1, 6 = 4+2, 4 = 4. Владелец может читать, изменять и запускать файл; члены группы-владельца — читать и изменять, но не запускать; все прочие — только читать. Если бы речь шла о каталоге, те же цифры читались бы иначе: владелец — полный доступ, группа 6 без x практически бесполезна (нельзя ни зайти, ни обратиться к содержимому по имени), а 4 для остальных позволяет только получить список имён через ls, но не stat их и не открыть.

Полная форма — четыре цифры: 0764 означает, что setuid/setgid/sticky сняты; например 4755 — setuid, 2775 — setgid-каталог, 1777 — sticky. Осторожно с записью в коде: в Go/C 0764 — восьмеричное (в Go предпочтительно 0o764), а 764 — десятичное и значит совсем другое. Права 0764 сами по себе подозрительны: w для группы без явной необходимости — типичное замечание при аудите, а «исполняемый только владельцу» файл обычно означает, что кто-то делал chmod наугад.

Можно ли удалить файл с разрешениями 0644, которым владeeт root, от имени другого пользователя (естественно без sudo/su)?

Заголовок раздела «Можно ли удалить файл с разрешениями 0644, которым владeeт root, от имени другого пользователя (естественно без sudo/su)?»

Коротко. Да, можно — если у пользователя есть права w и x на каталог, где лежит файл, и на этом каталоге не выставлен sticky bit. Права и владелец самого файла на удаление не влияют вообще: unlink меняет каталог, а не файл.

Глубже. Проверка ядра при unlink(name) — это проверка MAY_WRITE|MAY_EXEC на родительский каталог, плюс, если у каталога стоит S_ISVTX (sticky), дополнительное условие: удаляющий должен быть владельцем файла, владельцем каталога или иметь CAP_FOWNER. Именно поэтому в /tmp (режим 1777) чужой root-овый файл удалить нельзя, а, скажем, в собственном каталоге ~/dir с режимом 755, куда root что-то положил, — легко. rm в интерактивном режиме ещё и переспросит («remove write-protected file?»), потому что видит, что у вас нет w на файл, но это поведение утилиты, а не запрет ядра: rm -f или unlink() из программы отработают молча.

Что реально помешает: sticky bit на каталоге; отсутствие w/x на каталоге; chattr +i или +a на файле либо на каталоге; ФС смонтирована ro; запрет со стороны SELinux/AppArmor; в отдельных сборках ядра — fs.protected_regular/protected_symlinks/protected_hardlinks (они ограничивают открытие и создание ссылок в sticky-каталогах, но не отменяют базовое правило). Симметричное следствие того же правила, о котором часто забывают: файл с правами 0000, принадлежащий root, тоже удаляется — а вот изменить его содержимое без прав w на файл нельзя.

Окно терминала
$ ls -ld /home/user/dir
drwxr-xr-x 2 user user 4096 Aug 11 12:00 /home/user/dir
$ ls -l /home/user/dir/root.txt
-rw-r--r-- 1 root root 0 Aug 11 12:00 /home/user/dir/root.txt
$ rm -f /home/user/dir/root.txt # успешно: важен каталог, а не файл
  • Говорят, что имя файла хранится в inode. Имя — в каталоге; inode хранит только метаданные и указатели на блоки.
  • Считают, что жёсткая ссылка — «ярлык на оригинал». Все жёсткие ссылки равноправны, «оригинала» не существует, есть только nlink.
  • Утверждают, что удаление файла сразу освобождает место. Место освобождается при nlink == 0 и нуле открытых описаний.
  • Путают уровни при разговоре о дескрипторах: не различают таблицу fd процесса и open file description, из-за чего не могут объяснить dup2, O_APPEND после fork и разницу между FD_CLOEXEC и O_NONBLOCK.
  • Считают журнал гарантией сохранности данных. Он гарантирует согласованность метаданных; данные без fsync теряются, а в data=writeback в новом файле после сбоя может оказаться мусор.
  • На вопрос про «место есть, а писать нельзя» отвечают только «удалённые открытые файлы» и забывают про df -i (иноды) и 5% резерв root.
  • Думают, что для удаления файла нужны права на файл. Нужны права на каталог; sticky bit — единственное, что это правило ограничивает.
  • Считают, что setuid работает на shell-скриптах в Linux (не работает) и что sticky bit на файле что-то значит (не значит).
  • Пишут в коде os.Chmod(p, 644) без ведущего нуля и рассчитывают, что os.WriteFile(p, b, 0666) даст ровно 0666 — umask срежет.
  • Называют /proc и /sys «каталогами на диске» и не могут объяснить, что содержимое генерируется ядром на лету.
  • man-страницы: inode(7), path_resolution(7), open(2), unlink(2), symlink(7), chmod(2), credentials(7), capabilities(7), acl(5), proc(5), sysfs(5)https://man7.org/linux/man-pages/
  • Kernel documentation: «Overview of the Linux Virtual File System» — https://www.kernel.org/doc/html/latest/filesystems/vfs.html и «ext4 Data Structures and Algorithms» — https://www.kernel.org/doc/html/latest/filesystems/ext4/
  • Robert Love, «Linux Kernel Development», главы 13–14 (VFS, block I/O) и Bovet & Cesati, «Understanding the Linux Kernel», глава 12.
  • Michael Kerrisk, «The Linux Programming Interface» — главы 4–5 (файловый ввод-вывод и дескрипторы), 14–15 (файловые системы, атрибуты файлов), 18 (каталоги и ссылки).
  • Документация Go: пакет oshttps://pkg.go.dev/os (Chmod, FileMode, Root) и исходник src/os/file_posix.go (функция syscallMode).
  • Release notes Go 1.19 (автоматический подъём RLIMIT_NOFILE) — https://go.dev/doc/go1.19 и Go 1.24 (os.Root) — https://go.dev/doc/go1.24

Список исходных вопросов с привязкой к компаниям: ../questions/filesystem.md