1.2Файлы и диски
Увидишь, почему имя файла и его inode - разные вещи, откуда берутся расхождения df и du и когда write ещё не означает запись на диск. Разберёшь права каталогов, page cache, монтирования и безопасную замену файла.
- Чем hard link отличается от symlink и почему
dfиduрасходятся. - Как вернуть место от удалённого лога, не перезапуская сервис.
- Что даёт бит
xна каталоге, зачем/tmpsticky 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.
Удалённый файл, который занимает место
Кроме имён ядро учитывает, кто держит 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_centisecs | 3000 | страница, грязная дольше 30 с, уходит на диск при ближайшей фоновой записи |
vm.dirty_writeback_centisecs | 500 | период фоновой записи; в ВМ Docker Desktop 1500 |
vm.dirty_background_ratio | 10 | доля доступной памяти, после которой запись идёт в фоне |
vm.dirty_ratio | 20 | доля, на подходе к которой пишущий процесс засыпает в write, пока фоновые потоки пишут на диск |
В этой ВМ 100 МиБ без sync пролежали грязными не меньше 10 секунд, а на сервере с
десятками гигабайт памяти 10 % означают гигабайты «записанных» данных. Гарантию даёт
fsync(fd) (в Go f.Sync()): он возвращается, когда устройство сообщило о
записи данных и метаданных файла. close записи на диск не гарантирует.
После ошибки записи на устройство страницы могут оказаться помеченными как чистые, и повторный
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 файл пуст или неполон от первого
вызова до последнего. При записи через временный файл имя до 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 скобки отмечают
выбранный планировщик ввода-вывода диска.
Вопросы
4du его не видит, а блоки заняты, пока процесс не закроет дескриптор. Находят через
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) с округлением вверх (так ведёт себяdf9.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 в пределах одной файловой системы атомарен, поэтому по имени всегда лежит целый файл, старый или новый».
-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 флаг обязателен, в поде всё так же.
Когда кэш не успевает освободиться
На быстром диске ВМ это лотерея: 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 так же считаются в лимит
памяти записавшего их контейнера.
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, не делая конфигурацию доступной всем.