Операционные системы Linux для программиста: базовый курс всего необходимого
0%

Linux для программиста: базовый курс всего необходимого

Linux для программиста: базовый курс всего необходимого

Есть два способа знать Linux. Первый — знать команды: выучить сотню утилит и гуглить остальное. Второй — знать модель: понимать, из каких сущностей состоит система и по каким правилам они соединяются. Первый способ работает ровно до первой нестандартной ситуации. Второй позволяет вывести нужную команду самому и, что важнее, объяснить, почему приложение ведёт себя странно.

Эта статья про второй способ. Она не учебник по bash — командной строке посвящена следующая статья трека. Здесь мы собираем именно модель: что такое процесс с точки зрения ядра, что он наследует, что переживает exec, откуда берутся права, как программа находит свои библиотеки, где посмотреть правду о работающем сервисе и какие грабли из этой конструкции торчат чаще всего.

Проверочный вопрос, который отделяет знание команд от знания модели: почему логи вашего приложения не появляются в docker logs, пока процесс не упадёт? Если ответ «не знаю» — эта статья для вас; правильный ответ будет в разделе про буферизацию, и он не про Docker.

Карта: что вообще нужно знать

Ядро предоставляет прикладной программе на удивление мало абстракций. Практически всё, что вы делаете, — комбинация пяти вещей: процесс, файловый дескриптор, адресное пространство, имя в иерархии и учётные данные. Всё остальное — библиотеки поверх.

Дальше идём по этой карте, но не как по списку тем, а как по цепочке: рождение процесса → что он несёт с собой → как находит код → как его видно снаружи → как он умирает.

Всё есть файл, но интересное — дескриптор

Фраза «в Unix всё есть файл» звучит красиво и почти ничего не объясняет. Полезная формулировка другая: у ядра есть один универсальный тип ссылки на объект ввода-вывода — файловый дескриптор, маленькое неотрицательное целое. Файл на диске, TCP-сокет, канал, терминал, таймер (timerfd), сигнал (signalfd), сам процесс (pidfd), кусок памяти (memfd) — всё это дескрипторы. Поэтому read, write, close, poll работают со всем подряд, и поэтому epoll умеет ждать одновременно сеть, таймер и завершение процесса.

За числом «3» в вашем коде стоит три уровня косвенности, и путаница между ними — источник целого класса багов.

Три уровня за файловым дескриптором: таблица процесса, открытое описание, inode

Проверим это руками. Дескрипторы процесса видны как симлинки:

$ ls -l /proc/self/fd
lrwx------ 1 m m 64 Jul 16 11:38 0 -> /dev/pts/3
l-wx------ 1 m m 64 Jul 16 11:38 1 -> pipe:[179375679]
lrwx------ 1 m m 64 Jul 16 11:38 2 -> /dev/pts/3
lr-x------ 1 m m 64 Jul 16 11:38 3 -> /var/log/app.log

# А позиция чтения и флаги — этажом ниже, в fdinfo:
$ cat /proc/self/fdinfo/3
pos:    1024000
flags:  02100002        # O_RDWR|O_APPEND|O_LARGEFILE, восьмеричные
mnt_id: 29
ino:    3145727

Три практических следствия, каждое из которых регулярно кого-то кусает:

1. rm большого файла не освободил место. Имя из каталога удалено, но пока хоть один процесс держит дескриптор — inode жив вместе с блоками. Классика: удалили access.log, а nginx продолжает в него писать «в никуда».

$ df -h /var        # 98% занято
$ du -sh /var/log   # 200 МБ — не сходится!
$ lsof +L1          # файлы без имён, но с открытыми дескрипторами
nginx  1123 www-data 4w REG 8,1 41231085568 0 3145727 /var/log/access.log (deleted)
# Лечение: не kill, а сказать демону переоткрыть файлы
$ nginx -s reopen   # или systemctl reload

2. Дескрипторы наследуются через exec — и это утечка привилегий. Если ваш сервис открыл приватный ключ и запустил дочерний процесс, ребёнок получил доступ к ключу, даже если работает от другого пользователя. Лечение — флаг O_CLOEXEC при открытии (не fcntl после: между open и fcntl другой поток может успеть сделать fork):

/* Правильно: закрытие при exec выставляется атомарно вместе с открытием */
int fd = open("/etc/app/key.pem", O_RDONLY | O_CLOEXEC);
/* Аналогично: pipe2(fds, O_CLOEXEC), accept4(..., SOCK_CLOEXEC),
   socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0) */

В Go, Rust и Python дескрипторы по умолчанию CLOEXEC (в Python — с 3.4, PEP 446), в C и старом C++ — нет. Проверить, что утекает, можно так: ls -l /proc/$(pgrep -f child-proc)/fd.

3. Лимит NOFILE — не про файлы, а про соединения. Сервер на 50 000 соединений упрётся в EMFILE при мягком лимите 1024.

$ ulimit -Sn; ulimit -Hn      # мягкий и жёсткий лимит текущей сессии
1024
1048576
$ cat /proc/1234/limits | grep -i "open files"
Max open files            1024                 1048576              files

Мягкий лимит процесс может поднять сам до жёсткого — setrlimit(RLIMIT_NOFILE, ...). В systemd-юните это LimitNOFILE=65535. Исторически select() вообще не работал с дескрипторами ≥ 1024 (FD_SETSIZE), что ещё одна причина использовать epoll (см. Ввод-вывод и драйверы).

Буферизация stdio: главный «мистический» баг

Обещанный ответ про пропавшие логи. Стандартная библиотека C буферизует вывод, и режим буферизации выбирается по типу устройства за дескриптором в момент первой записи:

Куда пишем Режим Когда данные реально уходят
stdout в терминал построчный на каждом \n
stdout в pipe или файл полный, буфер ~4 КиБ когда буфер заполнен или при fflush/exit
stderr без буферизации сразу

В контейнере, под systemd, под nohup, в CI — stdout это pipe или файл, а не терминал. Значит, полная буферизация. Демонстрация на живой системе:

$ cat > b.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
int main(void){ printf("OUT\n"); fprintf(stderr,"ERR\n"); sleep(1); _exit(0); }
EOF
$ gcc -o bdemo b.c

$ ./bdemo 2>&1 | cat        # stdout — это pipe
ERR                          # OUT потерян полностью: _exit не сбрасывает буферы

$ stdbuf -o0 ./bdemo 2>&1 | cat
OUT
ERR

OUT не «задержался» — он исчез. С обычным exit() он бы появился, но в конце и не в том порядке. Именно поэтому в логах вида «ERR: не могу подключиться» отсутствует предшествующая строка «пробую подключиться к …».

Что с этим делать, по убыванию правильности:

  1. Логировать в stderr — он не буферизуется. Это причина, по которой почти все логгеры пишут в stderr, а не в stdout.
  2. Выключить буферизацию в самом приложении: setvbuf(stdout, NULL, _IONBF, 0) в C, python -u или PYTHONUNBUFFERED=1, $stdout.sync = true в Ruby. В Go буферизации os.Stdout нет вовсе — там эта проблема не возникает.
  3. Обернуть снаружи: stdbuf -oL ./app (подменяет режим через LD_PRELOAD, поэтому не работает со статически слинкованными бинарями и с Go) или запуск под псевдотерминалом: script -qec ./app /dev/null, unbuffer ./app.

Родственный сюрприз: SIGKILL не даёт сбросить буферы никогда. Если docker stop дошёл до SIGKILL (по умолчанию через 10 секунд после SIGTERM) — последние секунды логов вы не увидите.

Процесс: рождение, наследство, смерть

Единственный способ создать процесс в Unix — клонировать существующий. fork() делает копию вызывающего процесса, execve() заменяет содержимое адресного пространства новой программой, сохраняя при этом «оболочку» процесса. Разделение странное на первый взгляд, но именно оно даёт shell возможность настроить окружение ребёнка между этими двумя шагами — перенаправить дескрипторы, сменить пользователя, применить лимиты.

Что происходит с состоянием на каждом шаге — таблица, которую стоит запомнить:

Свойство Переживает fork Переживает exec
Открытые дескрипторы да (общее описание файла) да, кроме помеченных O_CLOEXEC
Память процесса копия (через copy-on-write) нет, заменяется полностью
Переменные окружения да задаются заново третьим аргументом execve
Текущий каталог, корень (chroot) да да
umask, лимиты rlimit да да
UID/GID да да, кроме setuid-бинарей
Обработчики сигналов да сбрасываются на действие по умолчанию
Маска заблокированных сигналов да да — частый источник багов
Потоки нет, остаётся только вызвавший нет
PID новый тот же

Две строки заслуживают отдельного внимания.

Маска сигналов переживает exec. Если родитель заблокировал SIGTERM и забыл разблокировать перед запуском ребёнка, ребёнок унаследует блокировку и перестанет реагировать на systemctl stop. Правило: перед execve восстанавливайте маску в пустую. posix_spawn с флагом POSIX_SPAWN_SETSIGMASK делает это за вас.

fork в многопоточной программе оставляет один поток. Остальные исчезают, но их мьютексы остаются в захваченном состоянии. Если поток держал внутренний замок аллокатора, любой malloc в ребёнке — вечная блокировка. Между fork и exec в многопоточной программе разрешены только async-signal-safe функции (signal-safety(7)). Практический вывод: не вызывайте fork вручную из многопоточного кода — используйте posix_spawn или vfork+exec, а в высокоуровневых языках — штатный запуск подпроцессов (в Python — subprocess с start_new_session, не os.fork).

Жизненный цикл и буквы в /proc

Состояние видно в ps (колонка STAT) и в /proc/PID/stat. Практическая ценность букв: всплеск D в ps aux | awk '$8 ~ /D/' означает, что процессы ждут диск или сетевую ФС — никакой профайлер CPU тут не поможет; зомби (Z, <defunct>) означают, что родитель не вызывает wait().

Отдельный случай — PID 1 в контейнере. Ядро не применяет к процессу с PID 1 действия сигналов по умолчанию: если у PID 1 нет явного обработчика SIGTERM, сигнал просто игнорируется. Отсюда классическое «docker stop всегда занимает 10 секунд»: SIGTERM уходит в никуда, и контейнер добивается SIGKILL. Плюс PID 1 обязан пожинать осиротевших внуков, иначе контейнер копит зомби. Лечение: либо обрабатывать SIGTERM в приложении, либо запускать под минимальным init — tini, dumb-init, docker run --init.

Сигналы: что нужно знать прикладнику

$ kill -l | head -4
 1) SIGHUP   2) SIGINT   3) SIGQUIT   4) SIGILL
 5) SIGTRAP  6) SIGABRT  7) SIGBUS    8) SIGFPE
Сигнал Кто шлёт Действие по умолчанию Как принято использовать
SIGTERM (15) kill, systemd, Docker завершение «мягкая остановка» — закрыть соединения и выйти
SIGINT (2) терминал по Ctrl+C завершение то же, но интерактивно
SIGHUP (1) закрытие терминала завершение у демонов — «перечитай конфиг»
SIGKILL (9) ядро, oom-killer, kill -9 завершение нельзя перехватить или заблокировать
SIGUSR1/2 кто угодно завершение своя семантика: ротация логов, дамп состояния
SIGPIPE (13) ядро при записи в закрытый канал завершение причина «молчаливой смерти» в пайплайне
SIGCHLD ядро игнорируется сигнал «ребёнок умер, пора wait()»

SIGPIPE стоит отдельного абзаца: head -1 закрывает свой вход после первой строки, и ваш писатель получает SIGPIPE и умирает без сообщений. В сетевом коде это же происходит при записи в закрытый сокет. Поэтому серверы почти всегда игнорируют SIGPIPE и разбирают EPIPE из write() (Go и Rust делают это за вас).

Два правила, которые нарушают чаще всего:

  • В обработчике сигнала можно вызывать только async-signal-safe функции. printf, malloc, любой логгер — нельзя: если сигнал пришёл внутри malloc, получите дедлок. Правильный паттерн — write(2) напрямую, или запись в volatile sig_atomic_t флаг, или signalfd/self-pipe, чтобы обработать сигнал в обычном цикле событий.
  • Медленный системный вызов, прерванный сигналом, возвращает EINTR. Если не установлен SA_RESTART, ваш read() вернёт −1 посреди нормальной работы. Любой блокирующий вызов нужно оборачивать в цикл повтора при EINTR.

Кто получает Ctrl+C: группы процессов и сессии

Ctrl+C не отправляется «программе на переднем плане» — он отправляется группе процессов переднего плана, всей сразу. Именно поэтому Ctrl+C убивает весь пайплайн, а не только последнюю команду.

Из этой схемы следуют вещи, которые иначе выглядят магией:

  • Фоновый процесс, читающий с терминала, останавливается сигналом SIGTTIN — он не в группе переднего плана, читать нельзя. Отсюда зависший command &, который «ничего не делает».
  • kill -TERM -4201 (с минусом) шлёт сигнал всей группе. Так корректно останавливают дерево процессов; kill 4201 убьёт только лидера, а дети осиротеют.
  • Код выхода 128+N в shell означает «убит сигналом N». 130 = Ctrl+C, 137 = SIGKILL (часто это oom-killer), 143 = SIGTERM. Увидели в CI код 137 — ищите нехватку памяти, а не баг в тестах.
  • nohup и setsid отвязывают процесс от терминала, чтобы SIGHUP при закрытии сессии до него не дошёл. systemd делает это штатно для всех сервисов.

Коды выхода в целом: 0 — успех, 1–125 — своё, 126 — файл не исполняемый, 127 — команда не найдена, 128+N — сигнал. Коды больше 255 обрезаются по модулю 256 (exit(256) даёт 0 — любимая ловушка в скриптах).

Права: три бита, которые решают всё

Модель прав в Unix намеренно примитивна: у объекта есть владелец, группа и три набора по три бита. Прикладнику нужно твёрдо знать четыре неочевидных момента.

$ ls -l /usr/bin/passwd /tmp /etc/shadow
-rwsr-xr-x 1 root root   68208 /usr/bin/passwd    # s — setuid
drwxrwxrwt 22 root root   4096 /tmp               # t — sticky
-rw-r----- 1 root shadow   1810 /etc/shadow

1. Права на каталог значат не то, что кажется.

Бит на каталоге Что даёт
r прочитать список имён (ls)
w создавать и удалять файлы в нём — включая чужие
x пройти сквозь каталог к содержимому (cd, открыть файл по пути)

Удаление файла — это модификация каталога, а не файла. Поэтому имея w на каталог, можно удалить файл, принадлежащий root, к которому нет вообще никакого доступа. Sticky-бит (t) на /tmp существует ровно для того, чтобы это запретить: удалять может только владелец файла. Каталог с --x (без r) — рабочий приём: доступ по точному пути есть, а перечислить содержимое нельзя.

2. umask вычитает права при создании. Приложение просит режим 0666, umask 022 убирает биты записи для группы и остальных, получается 0644. Демон, создающий файлы с приватными данными, должен либо выставлять umask(077), либо явно fchmod после создания. В systemd — директива UMask=0077.

3. setuid — не «запустить от root», а «сменить effective UID на владельца файла». У процесса три UID: real (кто запустил), effective (по кому проверяются права), saved (куда можно вернуться). setuid-бит на скриптах Linux игнорирует — только на бинарях, и это защита, а не недоработка. Файловые системы, смонтированные с nosuid, игнорируют бит вообще.

4. Вместо setuid-root сегодня используют capabilities. Классический случай — сервер, которому нужен порт ниже 1024:

# Плохо: setuid root ради одного bind()
$ sudo chown root:root myserver && sudo chmod u+s myserver

# Лучше: одна конкретная привилегия
$ sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
$ getcap /usr/local/bin/myserver
/usr/local/bin/myserver cap_net_bind_service=ep

# Ещё лучше: сокет открывает systemd и передаёт готовый дескриптор
# (socket activation — приложение вообще не имеет привилегий)

# Или просто разрешить всем непривилегированным биндиться от 80:
$ sysctl -w net.ipv4.ip_unprivileged_port_start=80

Подробно про capabilities, namespaces, SELinux и seccomp — в статье Безопасность и изоляция. Здесь достаточно правила: если тянет сделать chmod u+s или запустить сервис от root — почти наверняка есть точечная альтернатива.

Файловая иерархия: что где лежит и почему

FHS (Filesystem Hierarchy Standard) — не эстетика, а разделение по изменяемости и принадлежности: что можно смонтировать только для чтения, что бэкапить, что снести без последствий.

Путь Что там Правило для разработчика
/usr/bin, /usr/lib всё, что поставил пакетный менеджер руками не трогать никогда
/usr/local/… собранное вручную на этой машине сюда make install без пакета
/opt/<app> самодостаточные сторонние приложения сюда «всё своё в одном каталоге»
/etc конфигурация этой машины текст, под контролем версий, бэкапится
/var/lib/<app> изменяемое состояние приложения БД, данные — бэкапится
/var/log логи ротируется, может быть отдельным разделом
/var/cache, /tmp то, что можно потерять /tmp может чиститься при загрузке
/run runtime-состояние: pid-файлы, сокеты tmpfs, очищается при перезагрузке
/proc, /sys интерфейс к ядру, не файлы на диске только чтение состояния и sysctl
/dev/shm разделяемая память как файлы быстрый IPC между процессами

В современных дистрибутивах прошёл «usr merge»: /bin, /sbin, /lib — теперь симлинки на /usr/…, что позволяет монтировать /usr только для чтения и делать атомарные обновления образа. В домашнем каталоге правила задаёт XDG Base Directory: конфиг — в ~/.config/<app>, состояние — в ~/.local/state, кэш — в ~/.cache; сыпать точками в $HOME уже считается дурным тоном.

Различия между системами тут заметные: во FreeBSD базовая система лежит в /bin, /usr/bin, а всё из портов и пакетов — строго в /usr/local (включая /usr/local/etc); эта чёткая граница «база против стороннего» — сознательное отличие от Linux, где всё вперемешку. В OpenBSD дополнительно действует принцип «всё в базе аудировано». На macOS есть /System (защищённый SIP, только чтение), /Library, /opt/homebrew. Каноническое описание — man hier на любой из этих систем.

Ещё одна практическая деталь: пути на Linux — это байты, а не строки. Разрешено всё, кроме \0 и /; регистр значим; перевод строки в имени файла — валиден. macOS (APFS/HFS+) регистр обычно не различает и нормализует Unicode, Windows не различает регистр и запрещает набор символов. Отсюда классика: код собирается у разработчика на macOS и падает в Linux-CI, потому что import Utils не находит utils.py. Git это ловит настройкой core.ignorecase, но лучше просто не полагаться на регистр.

Как программа находит свой код: ELF и динамическая линковка

Исполняемый файл в Linux — ELF. Ядро при execve читает заголовок и решает: это ELF — загрузить сегменты по картам; это #! — запустить указанный интерпретатор; это другой зарегистрированный формат — отдать binfmt_misc (так работает прозрачный запуск .NET, Java-jar или бинарей чужой архитектуры под QEMU).

Про shebang: строка ограничена размером BINPRM_BUF_SIZE (256 байт в актуальных ядрах, 128 в старых), интерпретатору можно передать только один аргумент, и он не разбивается по пробелам. Отсюда #!/usr/bin/env -S python3 -u — флаг -S появился в coreutils специально чтобы обойти это ограничение.

Динамически слинкованный бинарь запускается не сам: в нём записан «интерпретатор»:

$ file /usr/bin/ls
ELF 64-bit LSB pie executable, x86-64, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=..., stripped

$ ldd /usr/bin/ls
    linux-vdso.so.1 (0x00007ffd2f5f4000)          # не файл — vDSO, отдаётся ядром
    libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
    /lib64/ld-linux-x86-64.so.2

Порядок поиска библиотек — тот самый, из-за которого «у меня работает, а в контейнере нет»:

Две ошибки внизу схемы — самые частые в жизни разработчика, и они разные:

cannot open shared object file — библиотека не найдена. Диагностика: ldd ./app покажет not found; LD_DEBUG=libs ./app покажет, где именно искали. Лечение: положить библиотеку в /usr/local/lib + ldconfig, либо собрать с -Wl,-rpath,'$ORIGIN/../lib' — тогда бинарь ищет библиотеки относительно себя и переносится вместе с каталогом.

version GLIBC_2.34 not found — библиотека найдена, но старее нужной. Это следствие того, что glibc совместим снизу вверх, но не сверху вниз: собранное на Ubuntu 24.04 (glibc 2.39) не запустится на Debian 11 (2.31). Обратное работает всегда. Правило: собирайте в образе с самой старой поддерживаемой glibc, а не в самом свежем.

# Какие версии символов требует бинарь на самом деле
$ objdump -T ./app | grep GLIBC_ | awk '{print $5}' | sort -Vu | tail -3
GLIBC_2.32
GLIBC_2.33
GLIBC_2.34

glibc против musl: почему падает именно в Alpine

Alpine Linux использует musl, и это не «просто другая libc». Отличия, которые вылезают в проде:

  • Разрешение имён. glibc идёт через NSS (/etc/nsswitch.conf), поддерживая LDAP, mDNS, systemd-resolved. musl NSS не поддерживает вообще, читает только /etc/hosts и /etc/resolv.conf, опрашивает все серверы параллельно и до версии 1.2.4 не умел переключаться на TCP при усечённом ответе — отсюда «в Alpine ломается DNS для больших DNS-ответов и для search-доменов».
  • Размер стека потока по умолчанию у musl исторически 128 КиБ против 8 МиБ у glibc. Код с глубокой рекурсией или большими локальными буферами падает по stack overflow только в Alpine.
  • Аллокатор. mallocng в musl оптимизирован под размер и предсказуемость, а не под многопоточную скорость. Приложения с интенсивным malloc в много потоков могут замедлиться в разы — типичная жалоба про «Python в Alpine медленнее».
  • Бинарная несовместимость. Собранное под glibc не запустится под musl и наоборот; Python-колёса manylinux требуют glibc, для musl нужны musllinux.

Вывод не «Alpine плохой», а «Alpine — другая ОС». Если нужен маленький образ с glibc — debian:*-slim, gcr.io/distroless/* или статическая сборка (Go, Rust с musl-таргетом собираются статически и от libc не зависят вовсе).

/proc и /sys: ядро как файловая система

/proc — главный интерфейс наблюдения в Linux и, что важнее, API, а не отладочная свалка. htop, ps, lsof, docker stats — все читают эти файлы.

Карта /proc/PID: восемь наборов состояния процесса и файлы, которые их показывают

Минимальный набор, который окупается ежедневно:

$ PID=$(pgrep -f myserver)

$ tr '\0' '\n' < /proc/$PID/environ | grep -i proxy   # окружение живого процесса
HTTPS_PROXY=http://10.0.0.1:3128

$ tr '\0' ' ' < /proc/$PID/cmdline; echo             # с какими аргументами запущен
/usr/local/bin/myserver --config /etc/app/prod.yaml

$ readlink /proc/$PID/cwd /proc/$PID/exe             # где работает и чем является
/var/lib/myapp
/usr/local/bin/myserver

$ ls /proc/$PID/fd | wc -l                           # сколько дескрипторов открыто
1043

$ cat /proc/$PID/smaps_rollup | head -5              # честная память
Rss:              412300 kB
Pss:              218840 kB
Shared_Clean:     390120 kB
Private_Dirty:    198740 kB

$ cat /proc/$PID/wchan; echo                         # в каком вызове ядра спит
ep_poll

/sys (sysfs) — про устройства и подсистемы ядра, а не про процессы: /sys/block/nvme0n1/queue/scheduler, /sys/class/net/eth0/mtu, /sys/fs/cgroup/…. Настройки ядра — через /proc/sys или sysctl (постоянные — в /etc/sysctl.d/*.conf).

Три sysctl, о которые разработчики спотыкаются чаще всего:

$ sysctl fs.inotify.max_user_watches        # 8192 по умолчанию на старых системах
fs.inotify.max_user_watches = 249377
# Причина ENOSPC в webpack/vite/VS Code «System limit for file watchers reached»

$ sysctl net.ipv4.ip_local_port_range       # диапазон исходящих портов
net.ipv4.ip_local_port_range = 32768 60999  # ~28к одновременных исходящих соединений

$ sysctl vm.max_map_count                   # лимит регионов mmap на процесс
vm.max_map_count = 65530                    # Elasticsearch требует 262144

Различия между ОС тут принципиальные. /proc — изобретение Plan 9, подхваченное Linux и доведённое до предела: там лежит всё. Во FreeBSD procfs объявлен устаревшим и обычно не смонтирован — те же данные берут через procstat(1) и sysctl kern.proc. В OpenBSD procfs удалён полностью. macOS /proc не имеет никогда: аналог — sysctl, libproc, lsof, dtrace. В Windows NT эквивалент — объектный менеджер и WMI/ETW, видимые через Process Explorer. Поэтому код, парсящий /proc, — это код, привязанный к Linux; кросс-платформенные библиотеки вроде psutil существуют именно чтобы спрятать эту разницу.

Память, лимиты и OOM: почему сервис убили

Три числа, которые показывают про память разное, и их путают постоянно:

  • VSZ (virtual size) — сколько адресного пространства зарезервировано. Почти бесполезно: JVM или Go-рантайм резервируют десятки гигабайт виртуального адресного пространства, не трогая физическую память.
  • RSS (resident set size) — сколько физических страниц сейчас отображено. Ближе к правде, но разделяемые страницы (та же libc) считаются полностью каждому процессу — сумма RSS по всем процессам легко превышает объём ОЗУ.
  • PSS (proportional set size) — разделяемая страница делится на число пользователей. Единственная величина, которую осмысленно суммировать. Смотреть в smaps_rollup.

Когда память кончается, приходит oom-killer. Он не убивает «самый жадный» процесс — он считает oom_score (грубо: доля используемой памяти, скорректированная oom_score_adj в диапазоне −1000…1000) и убивает победителя SIGKILL.

$ dmesg -T | grep -i "killed process"
[Wed Jul 16 03:14:02] Out of memory: Killed process 4471 (python3)
  total-vm:8419264kB, anon-rss:7912440kB, file-rss:0kB, oom_score_adj:0

# Защитить критичный процесс (отрицательное = меньше шанс быть убитым)
$ echo -500 > /proc/$PID/oom_score_adj
# В systemd-юните: OOMScoreAdjust=-500

В cgroup v2 (а это все контейнеры и все systemd-сервисы) картина другая: лимит проверяется не по всей машине, а по группе, и OOM происходит внутри неё:

$ cat /proc/self/cgroup
0::/user.slice/user-1007.slice/session-11986.scope

$ CG=/sys/fs/cgroup/system.slice/myapp.service
$ cat $CG/memory.max $CG/memory.current
2147483648
1904214016
$ grep oom_kill $CG/memory.events
oom_kill 3            # три раза уже убивали — вот вам причина «рестартов на ровном месте»

Тот самый «код выхода 137» — это 128 + 9, то есть SIGKILL, и в 90% случаев это OOM внутри cgroup, а не баг в коде. Проверять надо memory.events, а не логи приложения.

Важная тонкость: page cache тоже считается в memory.current. Приложение, читающее большие файлы, «занимает» лимит кэшем, и это нормально — под давлением кэш вытесняется. Паниковать надо, когда растёт anon в memory.stat. Детали — в Управление памятью.

Собственный сервис под systemd

Практический минимум для «правильно задеплоить своё приложение». Юнит — обычный ini-файл в /etc/systemd/system/myapp.service:

[Unit]
Description=My application
# После сети и, если нужно, после БД на этой же машине
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=notify              # приложение само скажет «я готов» через sd_notify;
                         # если не умеет — Type=exec (не simple: exec ждёт успешный execve)
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
WorkingDirectory=/var/lib/myapp
EnvironmentFile=-/etc/myapp/env    # минус = «не ошибка, если файла нет»

# Кто мы
DynamicUser=yes          # systemd создаст временного пользователя сам
StateDirectory=myapp     # /var/lib/myapp с правильным владельцем — создаётся автоматически
RuntimeDirectory=myapp   # /run/myapp, удаляется при остановке

# Перезапуски
Restart=on-failure
RestartSec=2s
StartLimitBurst=5        # не крутить рестарт-цикл бесконечно

# Лимиты
LimitNOFILE=65535
MemoryMax=2G
CPUQuota=200%            # два ядра
OOMScoreAdjust=-100

# Песочница — почти бесплатно и сильно сокращает поверхность атаки
NoNewPrivileges=yes
ProtectSystem=strict     # весь / только для чтения, кроме StateDirectory
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service

# Корректная остановка
KillSignal=SIGTERM
TimeoutStopSec=30        # столько ждём graceful shutdown, потом SIGKILL

[Install]
WantedBy=multi-user.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now myapp
$ systemctl status myapp
$ journalctl -u myapp -f --output=cat      # логи; --output=cat убирает префиксы
$ systemd-analyze security myapp.service   # оценка «песочницы», 0 = идеал

Что из этого важно понимать прикладнику:

  • stdout/stderr сервиса уходят в journald автоматически — не нужно ни файлов логов, ни ротации. Отсюда важность раздела про буферизацию: буферизованный stdout = логи не в реальном времени.
  • Graceful shutdown — это обработчик SIGTERM. Без него systemctl stop через TimeoutStopSec перейдёт в SIGKILL с обрывом запросов.
  • Не пишите свой демонизатор. fork, setsid, закрытие дескрипторов, pid-файлы — всё это systemd (и launchd, и rc.d) делает лучше. Приложение должно оставаться на переднем плане. Про init-системы в семействах ОС — Системы инициализации.
  • Аналоги в других ОС: FreeBSD — скрипты /usr/local/etc/rc.d/ + rc.conf; OpenBSD — rcctl; macOS — plist для launchd; Windows — SCM и sc.exe. Модель везде та же: супервизор запускает процесс на переднем плане, следит и перезапускает.

Мелочи, которые ломают прод

Время. Есть CLOCK_REALTIME (может прыгать назад при синхронизации NTP или смене часового пояса) и CLOCK_MONOTONIC (не прыгает, но не тикает во время сна системы) и CLOCK_BOOTTIME (монотонные, включая сон). Любой замер длительности, таймаут, backoff обязан использовать монотонные часы: измерение через time() однажды даст отрицательную длительность. В Go это time.Since (использует монотонную составляющую), в Python — time.monotonic(), в C — clock_gettime(CLOCK_MONOTONIC, &ts). И то, и другое обычно не делает системного вызова вообще — работает через vDSO за ~25 нс.

Часовой пояс. Системный — симлинк /etc/localtime; переменная TZ переопределяет его для процесса. В контейнерах пакета tzdata часто нет, и всё становится UTC. Правильное решение — хранить и логировать в UTC всегда, переводить в локальное время только на границе с человеком.

Локаль. LANG/LC_* меняют поведение сортировки, регистра и разбора чисел. Классика: sort в скрипте даёт разный результат на машине разработчика и на сервере; printf "%.2f" печатает 3,14 вместо 3.14 в русской локали. В скриптах и в CI ставьте LC_ALL=C (или C.UTF-8, доступен как встроенный в glibc с 2.35). В Turkish locale "I".lower() даёт ı — знаменитый «turkish I bug», ломавший сравнение строк во множестве приложений.

Переносы строк и права в git. \r\n в shell-скрипте даёт bad interpreter: /bin/bash^M. Бит исполнения — часть индекса git (git update-index --chmod=+x), и в Windows он теряется.

Атомарность. Атомарна только замена файла через rename(2) в пределах одной ФС; запись поверх — нет. Правильный паттерн сохранения конфига: записать во временный файл рядом, fsync файла, rename, fsync каталога. Подробнее — в Файловых системах.

Пакеты и заголовки. Дистрибутивы разделяют библиотеку и файлы для сборки: libssl3 даёт .so для запуска, libssl-dev — заголовки и symlink для линковки. pkg-config --cflags --libs openssl — канонический способ узнать флаги. Ошибка fatal error: openssl/ssl.h: No such file означает отсутствие -dev-пакета, а не сломанную систему.

Типичные заблуждения

  • «root может всё». Уже давно нет: capabilities дробят полномочия, seccomp урезает системные вызовы, SELinux/AppArmor могут запретить root’у то, что разрешено обычному пользователю, а NoNewPrivileges рубит повышение привилегий целиком.
  • «Процесс использует столько памяти, сколько показывает top». RES — это RSS, куда входят разделяемые страницы, и не входит вытесненное в swap. Единственная суммируемая метрика — PSS.
  • «kill -9 — правильный способ остановить программу». Это способ гарантированно потерять несохранённые данные и оставить lock-файлы. Начинать всегда с SIGTERM.
  • «В контейнере другое ядро». Ядро одно на всю машину. Контейнер — это набор namespaces и cgroups поверх того же ядра, поэтому sysctl хоста, версия ядра и уязвимости ядра общие. См. Виртуализация и контейнеры.
  • «/proc/cpuinfo показывает доступные приложению ядра». В контейнере он показывает ядра хоста, а не квоту cgroup. Рантаймы, вычисляющие размер пула потоков по nproc, в контейнере с CPUQuota=100% создают 64 потока на одно ядро. Читать надо /sys/fs/cgroup/cpu.max.
  • «Файл записан, когда write() вернул успех». Он в page cache. До диска он дойдёт после fsync() — или не дойдёт, если случится отключение питания.
  • «ldd безопасно запускать на чужом бинаре». В классической реализации ldd может выполнить код анализируемого файла. Для недоверенных бинарей — objdump -p или readelf -d.

Чек-лист: приложение, готовое к Linux-проду

  1. Логи — в stderr, без буферизации, без собственных файлов и ротации.
  2. Обработан SIGTERM: закрыть слушающий сокет, дождаться активных запросов, выйти с 0.
  3. Игнорируется SIGPIPE, обрабатывается EPIPE; блокирующие вызовы повторяются при EINTR.
  4. Все дескрипторы открываются с O_CLOEXEC; LimitNOFILE соответствует ожидаемой нагрузке.
  5. Конфигурация — из файла в /etc и переменных окружения; секреты — не в argv (/proc/PID/cmdline читают все).
  6. Состояние — в /var/lib/<app> (StateDirectory), временные файлы — через PrivateTmp.
  7. Работает не от root; при необходимости привилегий — capability или socket activation.
  8. Размер пулов считается по cgroup-лимитам, а не по nproc и /proc/meminfo.
  9. Все длительности — по монотонным часам; всё время хранится в UTC.
  10. Собирается против самой старой поддерживаемой glibc; известно, glibc это или musl.
  11. Есть healthcheck и Type=notify/sd_notify, чтобы супервизор знал реальную готовность.
  12. Проверено systemd-analyze security и strace -f -c на старте — нет неожиданных обращений к файлам и вызовов.

Мини-итог

Модель, которую стоит унести: процесс — это контейнер состояния (дескрипторы, память, учётные данные, лимиты, cgroup, каталоги, маска сигналов), которое создаётся через fork, частично переживает exec и полностью видно через /proc. Всё «магическое» поведение, с которым сталкивается разработчик, — пропавшие логи, зависший фоновый процесс, код 137, GLIBC_2.34 not found, ENOSPC при живом диске — это следствия конкретных, простых и документированных правил этой модели, а не капризы системы.

Дальше есть два направления углубления: вниз — как ядро реализует эти абстракции (Процессы и планирование, Системные вызовы и IPC), и вверх — как этим эффективно пользоваться из командной строки и как измерять (Наблюдаемость и производительность).

Источники

Что дальше

Мы построили модель системы. Теперь нужен инструмент, которым в этой модели работают ежедневно: shell как язык, конвейеры, поиск, обработка текста, управление процессами и пакетами.

Следующая статья: Командная строка и инструментарий Linux: shell, утилиты, процессы, пакеты.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов