voidlearn
 

1.2Файлы и диски

Увидишь, почему имя файла и его inode - разные вещи, откуда берутся расхождения df и du и когда write ещё не означает запись на диск. Разберёшь права каталогов, page cache, монтирования и безопасную замену файла.

Что сможешь объяснить после этой главы
  • Чем hard link отличается от symlink и почему df и du расходятся.
  • Как вернуть место от удалённого лога, не перезапуская сервис.
  • Что даёт бит x на каталоге, зачем /tmp sticky bit и как пустить Go-бинарь на 80-й порт без root.
  • Где лежат байты между write и диском и как записать файл из Go, чтобы после падения не остался обрубок.
  • Почему запись файлов в контейнере расходует его лимит памяти и что показывает /proc/<pid>.

Примеры сняты в контейнерах debian:13 под Docker Desktop 29.8 (ядро 7.0.12 в его виртуальной машине), Go 1.27.1. Номера inode, размеры и часть sysctl у тебя будут свои.

Имя лежит в каталоге, файл лежит в inode

Каталог устроен как таблица из двух колонок: имя и номер inode. Inode хранит всё остальное: тип, права, владельца, размер, времена, счётчик ссылок и адреса блоков. Имени в inode нет, а номер уникален только внутри одной файловой системы. После open процесс работает с inode через дескриптор (глава 1.1), и судьба имени его больше не касается.

$ echo 'port: 8080' > app.yaml
$ ls -li app.yaml
55190 -rw-r--r-- 1 root root 11 Sep 16 21:24 app.yaml
$ stat app.yaml
  File: app.yaml
  Size: 11        	Blocks: 8          IO Block: 4096   regular file
Device: 0,60	Inode: 55190       Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2026-09-16 21:24:44.720859007 +0000
Modify: 2026-09-16 21:24:44.721930090 +0000
Change: 2026-09-16 21:24:44.721930090 +0000
 Birth: 2026-09-16 21:24:44.720859007 +0000

Links: 1 означает, что на inode смотрит одно имя. Blocks считается по 512 байт, и восемь единиц составляют один блок на 4 КиБ. Change часто принимают за время создания, а он сдвигается при любой правке inode: смене прав, новой ссылке, записи в файл. Создание записано в Birth.

Hard link и symlink

ln добавляет в каталог ещё одну строку с тем же номером inode. Это жёсткая ссылка, и «оригинала» у inode нет: все имена равноправны. ln -s заводит новый inode типа symlink, внутри которого лежит строка с путём.

$ ln app.yaml app-hard.yaml
$ ln -s app.yaml app-sym.yaml
$ stat -c '%n inode=%i links=%h size=%s %F' app.yaml app-hard.yaml app-sym.yaml
app.yaml inode=55190 links=2 size=11 regular file
app-hard.yaml inode=55190 links=2 size=11 regular file
app-sym.yaml inode=55191 links=1 size=8 symbolic link
$ rm app.yaml
$ cat app-hard.yaml
port: 8080
$ cat app-sym.yaml
cat: app-sym.yaml: No such file or directory
$ echo 'port: 9090' > app.yaml
$ ls -li app.yaml
55192 -rw-r--r-- 1 root root 11 Sep 16 21:24 app.yaml
$ cat app-sym.yaml
port: 9090
$ ln app-hard.yaml /data/app-hard.yaml
ln: failed to create hard link '/data/app-hard.yaml' => 'app-hard.yaml': Invalid cross-device link

Размер symlink 8 байт равен длине строки app.yaml. rm вызывает unlink: убирает строку из каталога и уменьшает счётчик, а inode живёт, пока на него смотрит хоть одно имя. Symlink помнил имя, а не номер: без имени повис, а с новым app.yaml повёл к другому inode, 55192. Путь в symlink разбирается при каждом обращении, относительный считается от каталога ссылки. Жёсткую ссылку нельзя сделать на каталог (. и .. ядро заводит само) и в другую файловую систему, где номер inode ничего не значит: /data здесь отдельный tmpfs.

Каталог /srv/demo app.yaml 55190 app-hard.yaml 55190 app-sym.yaml 55191 inode 55190 обычный файл, 0644, root links = 2, size = 11 открыт процессами: 1 адреса блоков данных блоки на диске port: 8080 inode 55191 symlink, links = 1, size = 8 путь: app.yaml имя ищется заново при каждом открытии процесс fd 4 открытый файл в ядре смещение, флаги Когда место освобождается Блоки свободны, когда links = 0 и файл никто не держит открытым. rm только убирает имя. Пока дескриптор открыт, df считает место занятым.
Имя, inode и данные. Два имени ведут к одному inode, symlink хранит только путь и ищет его при каждом обращении, а открытый дескриптор держит inode независимо от имён.

Удалённый файл, который занимает место

Кроме имён ядро учитывает, кто держит inode открытым, и отдаёт блоки, только когда не осталось ни того, ни другого. Типичный случай: сервис открыл лог при старте, файл удалили, а место не вернулось. Программа на Go applog открывает лог с O_APPEND, пишет 200 МиБ и раз в секунду добавляет строку. /data здесь tmpfs, чтобы df показывал только наш файл; на корне контейнера (overlay поверх ext4) результат тот же.

$ mkdir -p /data/log; applog /data/log/app.log 200 &
[1] 13
pid 13 wrote 200 MiB to /data/log/app.log
$ rm /data/log/app.log
$ df -h /data; du -sh /data
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           512M  201M  312M  40% /data
0	/data
$ lsof -nP +L1
COMMAND PID USER FD   TYPE DEVICE  SIZE/OFF NLINK NODE NAME
applog   13 root 4w   REG  0,118 209715252     0    3 /data/log/app.log (deleted)
$ ls -l /proc/$(pgrep applog)/fd | grep deleted
l-wx------ 1 root root 64 Sep 16 22:07 4 -> /data/log/app.log (deleted)

du обходит каталоги и складывает размеры найденных файлов, удалённого среди них нет. df спрашивает у файловой системы число занятых блоков (вызов statfs), а блоки всё ещё принадлежат inode. lsof +L1 отбирает открытые файлы, у которых меньше одной ссылки. lsof есть не в каждом образе, а /proc/<pid>/fd есть всегда, и к пути удалённого файла ядро дописывает (deleted). Перезапуск сервиса вернёт место, но файл можно обрезать и сейчас: открытие /proc/13/fd/4 на запись открывает сам inode.

$ : > /proc/$(pgrep applog)/fd/4
$ df -h /data
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           512M     0  512M   0% /data
$ cat /proc/$(pgrep applog)/fd/4
2026-09-16T22:07:26Z tick
2026-09-16T22:07:27Z tick
2026-09-16T22:07:28Z tick

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

Место есть, а inode кончились

Бывает и наоборот: запись падает с «No space left on device», а df -h показывает свободное место. На ext4 число inode задаётся при создании файловой системы, и на множестве мелких файлов inode кончаются раньше блоков. На tmpfs лимит задаёт опция nr_inodes, у /small здесь 1000:

$ cd /small
$ for i in $(seq 1 1000); do echo x > f$i || break; done
bash: f1000: No space left on device
$ df -h /small; df -i /small
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            64M  4.0M   61M   7% /small
Filesystem     Inodes IUsed IFree IUse% Mounted on
tmpfs            1000  1000     0  100% /small

Один inode занят корнем, и тысячный файл не поместился. ENOSPC одна на оба случая (в Go errors.Is(err, syscall.ENOSPC)), поэтому смотрят и df -h, и df -i. mkfs.ext4 по умолчанию заводит inode на каждые 16 КиБ: на 10 ГиБ это 655 360 inode, с -i 4096 уже 2 621 440. XFS выделяет inode по мере надобности, в пределах доли места maxpct.

Права: rwx, umask и особые биты

В -rw-r--r-- первый символ задаёт тип, дальше идут три тройки rwx: владелец, группа, остальные. Восьмерично тройка становится цифрой (r = 4, w = 2, x = 1), поэтому 0644 и есть rw-r--r--. Ядро берёт одну тройку: владельцу тройку владельца, члену группы файла тройку группы, остальным последнюю. Тройки не складываются, и владелец может получить отказ там, где остальным можно. Root проверку обходит благодаря capability CAP_DAC_OVERRIDE и читает даже файл с правами 000.

$ as_nobody() { setpriv --reuid=65534 --regid=65534 --clear-groups "$@"; }
$ echo hello > own.txt; chown nobody own.txt; chmod 074 own.txt; ls -l own.txt
----rwxr-- 1 nobody root 6 Sep 16 21:25 own.txt
$ as_nobody cat own.txt
cat: own.txt: Permission denied

Права нового файла просит программа, а umask процесса вычитает биты: touch просит 0666, mkdir просит 0777, и при umask 027 выходит 0640 и 0750. Так же обрезается 0o644 в os.WriteFile.

На каталоге буквы значат другое. r разрешает прочитать список имён, x даёт пройти через каталог к inode, а w даёт создавать, удалять и переименовывать записи, причём только вместе с x.

$ mkdir box; echo secret > box/a.txt; chmod 744 box
$ as_nobody ls -l box
ls: cannot access 'box/a.txt': Permission denied
total 0
-????????? ? ? ? ?            ? a.txt
$ chmod 711 box
$ as_nobody ls box
ls: cannot open directory 'box': Permission denied
$ as_nobody cat box/a.txt
secret
$ mkdir shared; chmod 777 shared; echo data > shared/root.txt
$ as_nobody sh -c 'echo x >> shared/root.txt'
sh: 1: cannot create shared/root.txt: Permission denied
$ as_nobody rm -f shared/root.txt; ls -l shared
total 0

С одним r ls получил имена, но ни одного inode, с одним x файл с известным именем открывается. Удаление меняет каталог, а не файл, и nobody удалил файл, в который не мог писать. В /tmp от этого защищает sticky bit (t, 1000): удалять и переименовывать записи там могут только владелец файла, владелец каталога и root. setgid на общем каталоге (s у группы, 2000) даёт новым файлам его группу. С setuid на исполняемом файле (s у владельца, 4000) программа работает с uid владельца файла, так passwd пишет в /etc/shadow. На скрипты Linux этот бит не применяет, а опция nosuid и флаг no_new_privs его отключают.

$ echo data > /tmp/root.txt; as_nobody rm -f /tmp/root.txt
rm: cannot remove '/tmp/root.txt': Operation not permitted

Capabilities вместо root

Проверку «uid 0 может всё» ядро разрезало на capabilities: CAP_NET_BIND_SERVICE разрешает слушать порты ниже 1024, CAP_SYS_ADMIN монтировать и многое другое. На ядре 7.0 их 41 (cap_last_cap = 40). Capability можно выдать процессу, а можно записать в расширенный атрибут исполняемого файла security.capability, и процесс получит её при exec.

Проверим на Go-программе, которая слушает :80 и печатает CapEff из /proc/self/status. Docker опускает в контейнерах порог привилегированных портов до нуля, так что контейнер запущен с --sysctl net.ipv4.ip_unprivileged_port_start=1024, как на обычном сервере.

$ as_nobody /usr/local/bin/listen80
listen tcp :80: bind: permission denied
$ setcap cap_net_bind_service=+ep /usr/local/bin/listen80
$ getcap /usr/local/bin/listen80
/usr/local/bin/listen80 cap_net_bind_service=ep
$ as_nobody /usr/local/bin/listen80
listening on [::]:80 as uid 65534
CapEff:	0000000000000400
$ cp /usr/local/bin/listen80 /tmp/listen80; getcap /tmp/listen80
$ cat /opt/bin/listen80 > /usr/local/bin/listen80; getcap /usr/local/bin/listen80
$ as_nobody /usr/local/bin/listen80
listen tcp :80: bind: permission denied

p (permitted) кладёт capability в разрешённый набор, e (effective) сразу её включает. CapEff записан битовой маской, и бит 10 (0x400) означает cap_net_bind_service. Атрибут принадлежит файлу: cp без --preserve=xattr его теряет, перезапись содержимого его стирает, и после выкладки бинаря setcap повторяют. В образ атрибут ставят в стадии сборки, COPY --from в BuildKit его не теряет (проверено на Docker 29.8).

Флаг no_new_privs (--security-opt no-new-privileges в Docker, allowPrivilegeEscalation: false в Kubernetes) запрещает exec давать процессу привилегии сверх прежних: setuid-бит перестаёт действовать, а capability из файла достаётся, только если процесс и до вызова держал её в разрешённом наборе (permitted).

Page cache: где байты между write и диском

write в обычный файл диска не ждёт. Ядро копирует байты в page cache, помечает страницы грязными и возвращает управление, а на диск их позже отправляют фоновые потоки ядра.

$ dd if=/dev/urandom of=blob bs=1M count=300 status=none
$ grep -E '^(Dirty|Writeback):' /proc/meminfo
Dirty:            307468 kB
Writeback:             0 kB
$ grep -E '^(file|file_dirty|file_writeback) ' /sys/fs/cgroup/memory.stat
file 314580992
file_dirty 314572800
file_writeback 0
$ sync blob
$ grep -E '^(file|file_dirty|file_writeback) ' /sys/fs/cgroup/memory.stat
file 314580992
file_dirty 0
file_writeback 0

dd завершился, а 300 МиБ числятся в Dirty: пропади сейчас питание, данных не будет, хотя каждый write вернул успех (Writeback считает страницы, которые уже уходят на устройство). sync blob вызывает fsync и ждёт. В memory.stat контейнера грязных страниц не осталось, а file не уменьшился: кэш стал чистым и по-прежнему входит в память контейнера. Без sync всё решают sysctl:

sysctlВ ядреЧто задаёт
vm.dirty_expire_centisecs3000страница, грязная дольше 30 с, уходит на диск при ближайшей фоновой записи
vm.dirty_writeback_centisecs500период фоновой записи; в ВМ Docker Desktop 1500
vm.dirty_background_ratio10доля доступной памяти, после которой запись идёт в фоне
vm.dirty_ratio20доля, на подходе к которой пишущий процесс засыпает в write, пока фоновые потоки пишут на диск

В этой ВМ 100 МиБ без sync пролежали грязными не меньше 10 секунд, а на сервере с десятками гигабайт памяти 10 % означают гигабайты «записанных» данных. Гарантию даёт fsync(fd) (в Go f.Sync()): он возвращается, когда устройство сообщило о записи данных и метаданных файла. close записи на диск не гарантирует.

Почему ошибку fsync нельзя повторить

После ошибки записи на устройство страницы могут оказаться помеченными как чистые, и повторный fsync вернёт успех, хотя данных нет. PostgreSQL наткнулся на это в 2018 году и с тех пор на ошибке fsync аварийно останавливается и восстанавливается из журнала. После ошибки Sync запись считают несостоявшейся.

Атомарная запись файла из Go

$ strace -f -qq -e trace=openat,write,fchmod,fsync,renameat,close -e signal=none atomicw plain config.json v2 2>&1 | grep '^\[pid'
[pid    21] openat(AT_FDCWD, "config.json", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0644) = 4
[pid    21] write(4, "{\"version\": \"v2\", \"workers\": 8}\n", 32) = 32
[pid    21] close(4)                    = 0
$ strace -f -o /dev/null -e inject=write:signal=SIGKILL:when=1 atomicw plain config.json v4
Killed
$ ls -l config.json
-rw-r--r-- 1 root root 0 Sep 16 21:25 config.json

os.WriteFile открывает файл с O_TRUNC, пишет и закрывает (grep убрал строки старта рантайма). До конца write на диске пустой или недописанный файл: strace убил процесс на первом write, и от конфига осталось 0 байт. После close данные ещё в page cache, и сбой питания может оставить файл пустым или недописанным. Файловые системы ext4 (эвристика auto_da_alloc), XFS и btrfs после такой обрезки начинают запись уже при закрытии, но это лишь сужает окно.

Надёжный способ опирается на rename: по man 2 rename существующее имя заменяется атомарно, и момента, когда его нет, не бывает.

func writeFileAtomic(path string, data []byte, perm os.FileMode) (err error) {
	dir := filepath.Dir(path)
	// Same directory, so rename stays within one file system.
	f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
	if err != nil {
		return err
	}
	defer func() {
		if err != nil {
			f.Close()
			os.Remove(f.Name())
		}
	}()

	if _, err = f.Write(data); err != nil {
		return err
	}
	if err = f.Chmod(perm); err != nil { // CreateTemp makes 0600
		return err
	}
	if err = f.Sync(); err != nil { // data and inode on disk before the name
		return err
	}
	if err = f.Close(); err != nil {
		return err
	}
	if err = os.Rename(f.Name(), path); err != nil {
		return err
	}

	d, err := os.Open(dir)
	if err != nil {
		return err
	}
	defer d.Close()
	return d.Sync() // persist the directory entry changed by rename
}
$ strace -f -qq -e trace=openat,write,fchmod,fsync,renameat,close -e signal=none atomicw atomic config.json v3 2>&1 | grep '^\[pid'
[pid    30] openat(AT_FDCWD, "./.config.json.tmp-2279600906", O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0600) = 4
[pid    34] write(4, "{\"version\": \"v3\", \"workers\": 8}\n", 32) = 32
[pid    34] fchmod(4, 0644)             = 0
[pid    34] fsync(4)                    = 0
[pid    34] close(4)                    = 0
[pid    34] renameat(AT_FDCWD, "./.config.json.tmp-2279600906", AT_FDCWD, "config.json") = 0
[pid    34] openat(AT_FDCWD, ".", O_RDONLY|O_CLOEXEC) = 4
[pid    34] fsync(4)                    = 0
[pid    34] close(4)                    = 0
$ atomicw atomic config.json v5
$ strace -f -o /dev/null -e inject=write:signal=SIGKILL:when=1 atomicw atomic config.json v6
Killed
$ strace -f -o /dev/null -e inject=renameat:signal=SIGKILL:when=1 atomicw atomic config.json v7
Killed
$ ls -la; cat config.json
total 16
drwxr-xr-x 2 root root 4096 Sep 16 21:25 .
drwxr-xr-x 1 root root 4096 Sep 16 21:25 ..
-rw-r--r-- 1 root root   32 Sep 16 21:25 .config.json.tmp-1711084816
-rw------- 1 root root    0 Sep 16 21:25 .config.json.tmp-3280935206
-rw-r--r-- 1 root root   32 Sep 16 21:25 config.json
{"version": "v5", "workers": 8}

Процесс убит на записи и на rename, а в config.json по-прежнему v5. Такой мусор сервису стоит подчищать при старте. Смена номера в [pid] значит, что горутина переехала на другой поток ОС.

os.WriteFile: файл обнуляется первым же вызовом openat(O_TRUNC) write close файл уже пустой, старых данных нет умер процесс: пустой или обрывок данные в page cache, сбой питания: обрывок strace убил процесс на первом write: config.json остался пустым, 0 байт Временный файл и rename: имя меняется одним шагом openat(tmp, O_EXCL) write, fchmod fsync(tmp) rename(tmp, name) fsync(каталог) config.json старый, tmp пустой: останется мусор config.json старый, tmp с данными в page cache config.json старый, tmp целиком на диске все видят новый; сбой питания может вернуть старый новый файл и новая запись каталога на диске
Где рвётся запись файла. У os.WriteFile файл пуст или неполон от первого вызова до последнего. При записи через временный файл имя до rename ведёт к старому файлу целиком, после него к новому.

Монтирование, tmpfs и overlay

Дерево от / собрано из точек монтирования: к каталогу подключается корень другой файловой системы, и прежнее содержимое каталога пропадает из виду. Вот дерево свежего контейнера с --tmpfs /data:size=64m:

$ findmnt -o TARGET,FSTYPE
TARGET                  FSTYPE
/                       overlay
|-/proc                 proc
| |-/proc/bus           proc
| |-/proc/fs            proc
| |-/proc/irq           proc
| |-/proc/sys           proc
| |-/proc/sysrq-trigger proc
| |-/proc/interrupts    tmpfs
| |-/proc/kcore         tmpfs
| |-/proc/keys          tmpfs
| |-/proc/scsi          tmpfs
| `-/proc/timer_list    tmpfs
|-/dev                  tmpfs
| |-/dev/pts            devpts
| |-/dev/mqueue         mqueue
| `-/dev/shm            tmpfs
|-/sys                  sysfs
| |-/sys/firmware       tmpfs
| `-/sys/fs/cgroup      cgroup2
|-/data                 tmpfs
|-/etc/hostname         ext4
|-/etc/hosts            ext4
`-/etc/resolv.conf      ext4
$ findmnt -no OPTIONS / | tr , '\n' | cut -d= -f1
rw
relatime
lowerdir
upperdir
workdir
nouserxattr

Корень смонтирован как overlay, который накладывает слои образа из lowerdir (только чтение) и слой записи контейнера из upperdir; подробно в статье «Docker», глава 2.1. /etc/hosts и соседей Docker монтирует отдельными файлами с диска ВМ. Опции у точек свои: ro запрещает запись, nosuid выключает setuid и capabilities файлов, nodev не даёт открывать файлы устройств, noexec запускать программы. Монтировать изнутри обычного контейнера нельзя: mount требует CAP_SYS_ADMIN.

$ findmnt -o TARGET,FSTYPE,OPTIONS /data
TARGET FSTYPE OPTIONS
/data  tmpfs  rw,nosuid,nodev,noexec,relatime,size=65536k
$ cp /opt/bin/listen80 /data/ && ls -l /data/listen80
-rwxr-xr-x 1 root root 3223186 Sep 16 21:25 /data/listen80
$ /data/listen80
bash: /data/listen80: Permission denied

tmpfs хранит файлы в page cache без диска под ним: size задаёт потолок, а не резерв, но записанное выгрузить некуда, кроме swap, и в контейнере оно идёт в лимит памяти. А точка монтирования прячет файлы, лежавшие в каталоге до неё: кэш записан в /srv/cache до подключения тома, и df считает место, которого не находит du (опыт с --cap-add SYS_ADMIN):

$ mkdir -p /srv/cache; dd if=/dev/zero of=/srv/cache/old.bin bs=1M count=300 status=none
$ mount -t tmpfs -o size=64m tmpfs /srv/cache
$ du -sh /srv/cache
0	/srv/cache
$ mkdir -p /mnt/root && mount --bind / /mnt/root
$ ls -l /mnt/root/srv/cache
total 307200
-rw-r--r-- 1 root root 314572800 Sep 16 21:25 old.bin

mount --bind подключает файловую систему ещё раз, без вложенных точек монтирования, и через /mnt/root видно, что лежит под tmpfs.

/proc и /sys: ядро в виде файлов

В /proc и /sys нет ни байта на диске: содержимое ядро собирает в момент чтения, поэтому ls показывает размер 0, а wc насчитывает килобайт. Вот каталог живого Go-процесса, HTTP-сервера с логом в /tmp/srv.log:

$ ls -l /proc/9/status; wc -c /proc/9/status
-r--r--r-- 1 root root 0 Sep 16 21:25 /proc/9/status
1089 /proc/9/status
$ grep -E '^(State|Threads|VmRSS|VmHWM|SigCgt|CapEff|NoNewPrivs|Seccomp):' /proc/9/status
State:	S (sleeping)
VmHWM:	    9384 kB
VmRSS:	    9384 kB
Threads:	5
SigCgt:	fffffffd7fc1fefd
CapEff:	00000000a80425fb
NoNewPrivs:	0
Seccomp:	2
$ ls -l /proc/9/fd
total 0
lr-x------ 1 root root 64 Sep 16 21:25 0 -> /dev/null
lrwx------ 1 root root 64 Sep 16 21:25 1 -> /dev/pts/0
lrwx------ 1 root root 64 Sep 16 21:25 2 -> /dev/pts/0
lr-x------ 1 root root 64 Sep 16 21:25 3 -> /sys/fs/cgroup/cpu.max
l-wx------ 1 root root 64 Sep 16 21:25 4 -> /tmp/srv.log
lrwx------ 1 root root 64 Sep 16 21:25 5 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Sep 16 21:25 6 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Sep 16 21:25 7 -> 'socket:[1896907]'
$ cat /proc/9/fdinfo/4
pos:	39
flags:	02402001
mnt_id:	375
ino:	55252

VmHWM хранит пик резидентной памяти, SigCgt маску сигналов с обработчиками. В CapEff a80425fb собраны 14 capabilities, которые Docker оставляет root, а Seccomp: 2 означает seccomp-фильтр системных вызовов. В fd ядро подставляет описание объекта: сокет с номером inode, eventpoll netpoller рантайма и eventfd, которым рантайм его будит. Дескриптор 3 тоже открыл рантайм: с Go 1.25 он подстраивает GOMAXPROCS под cpu.max и перечитывает файл не чаще раза в секунду. fdinfo хранит смещение pos, флаги open восьмерично (O_WRONLY = 1, O_APPEND = 02000, O_CLOEXEC = 02000000) и номер inode; по pos видно, как далеко процесс прочитал большой файл.

$ tr '\0' '\n' < /proc/9/environ | head -1
DB_PASSWORD=s3cr3t
$ as_nobody cat /proc/9/environ
cat: /proc/9/environ: Permission denied
$ cat /sys/fs/cgroup/memory.max /sys/block/vda/queue/scheduler
268435456
none [mq-deadline] kyber
$ echo max > /sys/fs/cgroup/memory.max
bash: /sys/fs/cgroup/memory.max: Read-only file system

В environ окружение запуска лежит открытым текстом. Прочитает его тот же uid, в том числе через kubectl exec, который входит под тем же пользователем, а чужой, даже root без CAP_SYS_PTRACE, получит отказ. /sys (sysfs) раскладывает объекты ядра по каталогам, по значению в файле. В контейнер он смонтирован только на чтение: лимит своей cgroup видно, но не поменять, а в scheduler скобки отмечают выбранный планировщик ввода-вывода диска.

Вопросы

4
Суть: чаще всего место держит удалённый, но открытый файл: имени нет, и du его не видит, а блоки заняты, пока процесс не закроет дескриптор. Находят через lsof +L1 или (deleted) в /proc/*/fd, возвращают обрезкой через /proc/<pid>/fd/N. Реже виноваты файлы под точкой монтирования и резерв ext4.

Удалённый, но открытый файл

du обходит каталоги, df спрашивает у файловой системы занятые блоки, и разницу дают блоки, до которых не дойти по именам. Первым подозревают удалённый лог, который сервис держит открытым: find /proc/[0-9]*/fd -lname '*(deleted)' найдёт его без lsof, а : > /proc/<pid>/fd/N обрежет. Причину чинят отдельно: переоткрытием лога после ротации или записью в stdout.

Если файл открыт без O_APPEND

С O_APPEND запись идёт в текущий конец, и после обрезки лог растёт с нуля. Без него процесс пишет со своего смещения, и в начале файла остаётся дыра:

$ NOAPPEND=1 applog /data/log/app.log 100 &
[1] 38
pid 38 wrote 100 MiB to /data/log/app.log
$ rm /data/log/app.log
$ : > /proc/$(pgrep applog)/fd/4
$ ls -lsL /proc/$(pgrep applog)/fd/4
4 -rw-r--r-- 0 root root 104857704 Sep 16 22:07 /proc/38/fd/4
$ df -h /data
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           512M  4.0K  512M   1% /data

Размер снова 100 МиБ, а занято 4 КиБ (первая колонка ls -s): файл стал разреженным. То же бывает с copytruncate в logrotate, и по документации между копированием и обрезкой ещё и теряется часть записей.

Что ещё не видит du

  • Файлы под точкой монтирования. Записанное до монтирования тома не видно, но занимает место; смотрят через mount --bind / /mnt/root, где вложенных монтирований нет.
  • Резерв ext4. 5 % блоков отданы root (Reserved block count в dumpe2fs -h, меняется tune2fs -m). Use% в df считается как used / (used + avail) с округлением вверх (так ведёт себя df 9.7, хотя руководство coreutils пишет «used / size»), и 100 % значит, что места нет обычным пользователям, а root ещё пишет.
Удалить лог, чтобы освободить место

rm на логе живого процесса места не вернёт: исчезнет только имя. Очищают такой лог обрезкой по имени, : > app.log, и место возвращается сразу.

Суть: писать во временный файл в том же каталоге, вызвать f.Sync(), закрыть, сделать os.Rename поверх старого имени и Sync каталога. rename заменяет запись в каталоге за один шаг, и читатель или перезапущенный сервис видит либо старый файл целиком, либо новый.

Почему не os.WriteFile

Он открывает файл с O_TRUNC, и процесс, убитый на первом write, оставил 0 байт. После close данные ещё в page cache, и сбой питания может обнулить файл.

Зачем каждый шаг

ШагЧто будет без него
os.CreateTemp в каталоге целиrename в другую файловую систему или даже в другую точку монтирования вернёт EXDEV
f.Chmod(perm)останутся права 0600, которые ставит CreateTemp
f.Sync() до renameпосле сбоя питания имя может вести к inode без данных
Sync каталогапосле сбоя питания может вернуться старая запись: fsync файла не гарантирует записи в каталоге

Где рецепт перестаёт работать

$ atomicw rename /srv/app/config.json /data/config.json
rename /srv/app/config.json /data/config.json: invalid cross-device link
  • Разные точки монтирования. Временный файл в os.TempDir() и цель на томе контейнера не переименовать, а mv в таком случае молча копирует и удаляет, без атомарности.
  • Несколько писателей. От гонки rename не спасает, из двух замен останется последняя.

Читатель, открывший файл до замены, дочитает старую версию: его дескриптор держит старый inode. Тем же приёмом kubelet обновляет тома ConfigMap и Secret, только через rename переключается symlink ..data на каталог с новой версией.

Как сказать коротко

«Пишу во временный файл рядом, делаю fsync, rename поверх старого и fsync каталога. rename в пределах одной файловой системы атомарен, поэтому по имени всегда лежит целый файл, старый или новый».

Суть: проще всего слушать высокий порт и отдавать 80-й снаружи: Service в Kubernetes, -p 80:8080 в Docker. Если слушать нужно именно 80-й, хватает CAP_NET_BIND_SERVICE на файле, если контейнер её не отбирает. В контейнерах Docker с 20.10 и containerd с 2.0 порог привилегированных портов и вовсе опущен до нуля.

Порт снаружи

Процесс слушает 8080, а 80-й принимает Service с port: 80 и targetPort: 8080, публикация порта в Docker или балансировщик. От ядра и политик безопасности это не зависит.

Порог непривилегированных портов

Какие порты требуют capability, решает sysctl net.ipv4.ip_unprivileged_port_start (по умолчанию 1024). Он принадлежит сетевому namespace, и Docker с 20.10 ставит контейнерам 0, а CRI в containerd с 2.0 делает то же (enable_unprivileged_ports) для всех контейнеров, кроме работающих в сети хоста.

$ docker run --rm --cap-drop ALL --entrypoint sh listen80:cap -c "cp /usr/local/bin/listen80 /tmp/l && /tmp/l"
listening on [::]:80 as uid 65534
CapEff:	0000000000000000

Копия бинаря без атрибута, uid 65534, ни одной capability, а порт 80 открыт. Это ловушка: «работает без root» в контейнере ничего не говорит о голом сервере и о поде с hostNetwork: true, где тот же бинарь упадёт на bind: permission denied.

Capability и где она ломается

Образ listen80:cap собран так: setcap в стадии сборки, COPY --from в debian:13, USER 65534. Порог для опыта возвращён к 1024:

$ docker run --rm --sysctl net.ipv4.ip_unprivileged_port_start=1024 --cap-drop ALL listen80:cap; echo "exit=$?"
exec /usr/local/bin/listen80: operation not permitted
exit=255
$ docker run --rm --sysctl net.ipv4.ip_unprivileged_port_start=1024 --security-opt no-new-privileges --entrypoint sh listen80:cap -c /usr/local/bin/listen80
listen tcp :80: bind: permission denied
  • drop: [ALL] убирает capability из ограничивающего набора, и ядро отказывает в самом exec: бинарь с флагом e, не получивший всех своих capabilities, не запускается (man 7 capabilities). С --cap-add NET_BIND_SERVICE тот же образ стартует; профиль restricted в Pod Security Standards разрешает добавить только её.
  • no_new_privs не мешает, когда бинарь сам служит entrypoint: runc (Docker 29.8) запускает его уже от uid 65534, но с capabilities контейнера в permitted, и capability из файла остаётся. А sh теряет permitted при собственном запуске, и exec с этим флагом её уже не добавит. В профиле restricted флаг обязателен, в поде всё так же.
Суть: page cache записанных файлов учитывается в cgroup контейнера. Чистые страницы ядро вытесняет, а грязные и уходящие на устройство держит, пока диск их не примет. Если ими занят весь лимит, ядро диск не дожидается: несколько пустых попыток освободить память, и срабатывает OOM killer cgroup. Файлы в tmpfs без swap не вытесняются вовсе.

Когда кэш не успевает освободиться

На быстром диске ВМ это лотерея: dd на 1 ГиБ в контейнере с лимитом 256 МиБ получал OOMKilled от силы в половине запусков из восьми, а в иных сериях ни разу. Если урезать запись до 20 МБ/с (/dev/vda здесь диск ВМ), процесс убивают почти в каждом запуске; bigwrite пишет 400 МиБ по 1 МиБ, последний аргумент задаёт Sync через каждые N МиБ:

$ docker run -d --name dv-writer --memory 256m --memory-swap 256m --device-write-bps /dev/vda:20mb -v "$PWD/bin:/opt/bin:ro" debian:13 sleep infinity
a3959095027d91d46afd35a0483c4ebb1145faac89ac8241ac2589852127544c
$ docker exec dv-writer /opt/bin/bigwrite /srv/out.bin 400 0; echo "exit=$?"
exit=137
$ docker inspect -f '{{.State.Status}} OOMKilled={{.State.OOMKilled}}' dv-writer
running OOMKilled=true

В dmesg видно, чем была занята память cgroup:

[26570.304057] anon 2433024
[26570.304058] file 258211840
[26570.304061] file_dirty 0
[26570.304061] file_writeback 258211840
[26570.304108] Memory cgroup out of memory: Killed process 74323 (bigwrite) total-vm:1226976kB, anon-rss:2288kB, file-rss:4kB, shmem-rss:0kB, UID:0 pgtables:88kB oom_score_adj:0

Анонимной памяти 2,4 МБ, а весь лимит заняли страницы в writeback, и выбросить их нельзя, пока диск их не примет. Ждать ядро не станет: в cgroup v2 оно рассчитывает, что писателя притормозят лимиты грязных страниц, такие страницы пропускает и после нескольких пустых попыток зовёт OOM killer, хотя диску оставалось секунд двенадцать.

Что делать

Сбрасывать данные по ходу записи, чтобы грязных страниц не набиралось больше, чем принимает диск: с f.Sync() через каждые 32 МиБ тот же контейнер три раза из трёх записал 400 МиБ за 19 секунд, а без Sync в том же прогоне был убит пять раз из пяти. Помогает и запас лимита, если сервис регулярно пишет большие файлы.

tmpfs и emptyDir в памяти

$ docker rm -f dv-writer
dv-writer
$ docker run -d --name dv-writer --memory 256m --memory-swap 256m --tmpfs /scratch:size=1g debian:13 sleep infinity
b7bce221912909a32407868e2f874d59d227b8a8b0af8c0de3ab6bb768cd5a29
$ docker exec dv-writer dd if=/dev/zero of=/scratch/tmp.bin bs=1M count=400 status=none; echo "exit=$?"
exit=137
$ docker exec dv-writer rm /scratch/tmp.bin; echo "exit=$?"
OCI runtime exec failed: exec failed: unable to start container process: error executing setns process: signal: killed (possibly OOM-killed)
exit=128

Страницы tmpfs вытеснять некуда: dd убит, записанная часть держит почти весь лимит (docker stats показал 254.7MiB из 256MiB), и в контейнер не зайти даже удалить файл. В Kubernetes файлы в emptyDir с medium: Memory так же считаются в лимит памяти записавшего их контейнера.

Смотреть только на heap

pprof показывает память Go-процесса, а лимит cgroup считает ещё page cache и tmpfs. При OOMKilled у пишущего сервиса смотрят file, file_writeback и shmem в memory.stat.

Что стоит запомнить

  • Имя в каталоге указывает на inode; открытый дескриптор может пережить удаление имени.
  • Hard link - ещё одно имя того же inode, symlink хранит путь.
  • Для замены файла без частично записанного содержимого используют временный файл и атомарный rename в той же файловой системе.

Частые ошибки

  • Удаление файла не освобождает блоки, пока его держит открытым процесс.
  • Успешный write не равен гарантии сохранения данных после отключения питания.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Сверь ссылки после удаления

Прочитай команды и результат. Какая ссылка продолжит работать после удаления app.yaml, и почему новая запись по прежнему имени её не меняет?

$ ln app.yaml app-hard.yaml
$ ln -s app.yaml app-sym.yaml
$ rm app.yaml
$ cat app-hard.yaml
port: 8080
$ echo 'port: 9090' > app.yaml
$ cat app-sym.yaml
port: 9090
Ответ и разбор

app-hard.yaml продолжает указывать на исходный inode, поэтому в нём осталось port: 8080. Символическая ссылка содержит имя app.yaml, а не закреплённую цель. После удаления старого имени и создания нового symlink разрешается уже в новый файл с port: 9090.

Код-ревью

2. Сделай замену файла устойчивее

Этот код перезаписывает конфигурацию работающего сервиса. Найди риск при падении посередине и предложи безопасную последовательность записи.

func save(path string, data []byte) error {
    return os.WriteFile(path, data, 0o644)
}
Ответ и разбор

WriteFile открывает целевой файл на запись и может обрезать его до завершения записи; сбой оставит неполное содержимое. Создай временный файл в том же каталоге, запиши данные, проверь ошибки Sync и Close, затем замени цель через Rename. Если нужна устойчивость к потере питания, после rename синхронизируй каталог. Временный файл при ошибке удали.

Применение

3. Объясни занятое место после rm

du -sh /var/log/myapp показывает почти ноль, но df -h /var всё ещё показывает прежнюю занятость. Процесс сервиса не перезапускали. Как проверить гипотезу об удалённом открытом файле и освободить место?

Ответ и разбор

Посмотри lsof +L1 или символьные ссылки в /proc/<pid>/fd на файлы с пометкой (deleted). Пока процесс держит дескриптор, inode и блоки остаются занятыми, хотя имени уже нет. Попроси сервис закрыть и переоткрыть лог штатным сигналом или аккуратно перезапусти именно владельца дескриптора; простое повторное удаление имени не поможет.

Применение

4. Разбери права каталога

Файл /srv/app/config.yaml имеет владельца root:root и режим 0640, каталог /srv/app имеет владельца root:app и режим 0750, сервис запущен как пользователь app в группе app. Сможет ли процесс прочитать файл? Что надо проверить до смены прав?

Ответ и разбор

По указанным владельцу и группам процесс не попадает ни в класс владельца, ни в группу файла, поэтому права чтения для него отсутствуют. Кроме того, для прохода к файлу нужен бит execute на каждом каталоге пути. Проверь фактические supplementary groups процесса и права всех каталогов командой namei -l; затем выдай доступ через корректную группу или ACL, не делая конфигурацию доступной всем.