Процессы, порты и systemd

Процессы, порты и systemd

Ты деплоишь приложение, запускаешь - «port 8080 already in use». Кто-то занял порт. Или сервис упал ночью и не перезапустился. Всё это - про процессы.

Просмотр процессов

ps aux                        # все процессы
ps aux | grep nginx           # найти nginx
top                           # мониторинг в реальном времени (q - выход)
htop                          # красивый top (установить: apt install htop)

Что значат колонки в ps aux:

USER   PID  %CPU %MEM    VSZ   RSS TTY  STAT START   TIME COMMAND
root     1   0.0  0.1  16956  3456 ?    Ss   10:00   0:01 /sbin/init
user  1234   2.3  1.5 123456 15678 ?    Sl   10:05   0:30 ./backend
  • PID - ID процесса (нужен для kill)
  • %CPU / %MEM - потребление ресурсов
  • STAT - статус (S = sleeping, R = running, Z = zombie)

Буквы в STAT стоит выучить полностью, потому что они сразу называют причину проблемы:

STATЧто значитО чём говорит на проде
Rвыполняется или готов выполнятьсяпроцесс жрёт CPU
Sспит, ждёт события (сеть, таймер)норма для веб-сервиса
Dнепрерываемый сон, ждёт дискдиск или сетевая ФС не отвечает; kill -9 не подействует
Zзомби, завершился, но родитель не забрал код возвратабаг в родителе, см. ниже
Tостановлен сигналом (Ctrl+Z, SIGSTOP)кто-то забыл bg
< / Nповышенный / понижённый приоритетрезультат nice

Дерево процессов: PPID, сироты и зомби

У каждого процесса есть родитель (PPID). Дерево видно так:

ps -ef --forest | head -30     # с отступами
pstree -p 1234                 # поддерево конкретного PID
ps -o pid,ppid,stat,cmd -p 1234

Осиротевший процесс - тот, чей родитель умер раньше. Он не погибает: ядро переподвешивает его к PID 1 (в обычной системе это systemd). Именно поэтому запущенный через & процесс продолжает жить после exit, если успел отвязаться, но получает SIGHUP при обрыве терминала, если нет.

Зомби - обратная ситуация: процесс завершился, но родитель не вызвал wait() и не забрал код возврата. Запись в таблице процессов остаётся, чтобы этот код было кому прочитать.

ps aux | awk '$8 ~ /Z/ {print $2, $11}'    # найти зомби
ps -o ppid= -p 4242                         # кто их родитель
Зомби уже мёртв, убивать нечего: он не исполняет код и не держит память, только слот в таблице процессов. Лечится только родитель - его перезапуск (или `kill` по нему) заставит PID 1 подобрать зомби и очистить запись. Если зомби набегает тысячами, таблица процессов упирается в лимит `kernel.pid_max`, и система перестаёт создавать новые процессы - выглядит как «сервер не отвечает, и ssh не заходит».

Откуда зомби берутся в бэкенде: приложение вызывает внешнюю утилиту (git, ffmpeg, pg_dump) и не дожидается её завершения. Забытый wait есть в любом языке:

cmd := exec.Command("pg_dump", "mydb")
if err := cmd.Start(); err != nil {   // Start запускает и сразу возвращает управление
    return err
}
// Без cmd.Wait() дочерний процесс станет зомби после завершения.
// cmd.Run() = Start + Wait, поэтому в 95% случаев нужен именно Run.
return cmd.Wait()
<?php
declare(strict_types=1);

$descriptors = [1 => ['file', '/tmp/dump.sql', 'w'], 2 => ['pipe', 'w']];
$process = proc_open('pg_dump mydb', $descriptors, $pipes);
if (!is_resource($process)) {
    throw new RuntimeException('не удалось запустить pg_dump');
}

// proc_close() и делает wait(): дожидается процесса и забирает код возврата.
// Забыл её - дочерний процесс висит зомби, пока жив PHP-воркер.
// Для долгоживущих воркеров это утечка слотов в таблице процессов.
$exitCode = proc_close($process);

Управление процессами

Сравнение SIGTERM 15 и SIGKILL 9: вежливое и принудительное завершение

kill 1234              # попросить процесс завершиться (SIGTERM)
kill -9 1234           # убить принудительно (SIGKILL)
killall nginx          # завершить все процессы с именем nginx
`SIGKILL` не даёт процессу время на graceful shutdown. Сначала попробуй обычный `kill` (SIGTERM) - он позволяет закрыть соединения, дописать логи, завершить транзакции.

Сигналов больше двух, и половина повседневных задач решается не через рестарт:

СигналНомерПерехватываетсяТипичное применение
SIGTERM15давежливая просьба завершиться, дефолт kill и docker stop
SIGINT2дато же, что Ctrl+C
SIGHUP1да«перечитай конфиг»: kill -HUP $(pidof nginx) вместо рестарта
SIGUSR1 / SIGUSR210 / 12дачто решит приложение; nginx по USR1 переоткрывает лог-файлы после logrotate
SIGSTOP / SIGCONT19 / 18нет / дазаморозить и продолжить процесс, не убивая
SIGKILL9нетпоследний аргумент; обработчик не вызывается
kill -l                        # список всех сигналов
kill -HUP 1234                 # перечитать конфиг без остановки
kill -SIGTERM 1234             # имя вместо номера читается лучше

Разница между SIGTERM и SIGKILL для бэкендера прикладная: по SIGTERM Go-сервис успевает дождаться завершения активных HTTP-запросов и закрыть пул соединений с БД, а по SIGKILL транзакция обрывается на середине и клиент получает разорванное соединение. Как ловить сигнал в коде и передавать отмену вниз по стеку - в уроке про context.

PID 1 в контейнере: почему сигналы не доходят

Первый процесс в контейнере получает PID 1, а у PID 1 в Linux особые правила: сигналы, для которых не установлен обработчик, ему просто не доставляются. Ядро защищает init от случайного самоубийства. Отсюда две классические поломки.

Первая: docker stop висит 10 секунд и завершается убийством. Docker посылает SIGTERM, ваш процесс его не обрабатывает, ничего не происходит, по таймауту приходит SIGKILL. В логах контейнер уходит с кодом 137 (128 + 9), graceful shutdown не сработал.

Вторая: обработчик есть, но сигнал уходит не туда. Так бывает при shell-форме команды в Dockerfile:

CMD ./backend                   # shell form: PID 1 - это /bin/sh, backend - его ребёнок
CMD ["./backend"]               # exec form: PID 1 - сам backend, сигналы приходят ему

/bin/sh получает SIGTERM и не пересылает его детям. Правило простое: в CMD и ENTRYPOINT всегда используй JSON-массив, см. сборку образа по шагам.

Если процесс в контейнере плодит детей (nginx с воркерами, supervisor, обёртка-скрипт), нужен настоящий init: `docker run --init` подкладывает `tini` в PID 1, и тот пересылает сигналы и подбирает зомби. В compose это `init: true` у сервиса. Без него зомби внутри контейнера копятся ровно так же, как в системе, только с потолком по cgroup, а не по всей машине. Как это выглядит при отладке - в уроке про [логи и health-check контейнеров](../docker/08-debug-health-size.md).

Кто занял порт?

ss -tlnp                       # все слушающие TCP порты
ss -tlnp | grep 8080           # кто на порту 8080
lsof -i :8080                  # альтернатива (нужен lsof)

Пример вывода ss -tlnp:

State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port  Process
LISTEN  0       128     0.0.0.0:8080        0.0.0.0:*          users:(("backend",pid=1234,fd=3))
LISTEN  0       128     0.0.0.0:5432        0.0.0.0:*          users:(("postgres",pid=567,fd=5))

systemd - менеджер сервисов

На большинстве серверов сервисы управляются через systemd:

systemctl status nginx         # статус сервиса
systemctl start nginx          # запустить
systemctl stop nginx           # остановить
systemctl restart nginx        # перезапустить
systemctl enable nginx         # запускать при старте системы
systemctl disable nginx        # не запускать при старте

Логи через journalctl

journalctl -u nginx            # логи nginx
journalctl -u nginx -f         # следить в реальном времени
journalctl -u nginx --since "1 hour ago"   # за последний час
journalctl -u nginx --no-pager | tail -50  # последние 50 строк

Свой systemd-сервис

Файл /etc/systemd/system/myapp.service:

[Unit]
Description=My Go Backend
After=network.target postgresql.service

[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/backend
Restart=on-failure
RestartSec=5
Environment=DATABASE_URL=postgres://...
EnvironmentFile=/opt/myapp/.env

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload       # перечитать конфиги
sudo systemctl enable myapp        # включить автозапуск
sudo systemctl start myapp         # запустить
С `Restart=on-failure` systemd автоматически перезапустит сервис, если он упал с ненулевым exit code. Это бесплатный «мониторинг» для простых случаев.

Правки unit-файла и три команды, которые путают

sudo systemctl daemon-reload   # перечитать САМ unit-файл после его правки
sudo systemctl reload myapp    # послать сервису SIGHUP: перечитать свой конфиг
sudo systemctl restart myapp   # остановить и запустить заново

Забыть daemon-reload после правки unit-файла - ошибка номер один: systemd продолжает работать по старой версии, restart вроде проходит, а изменения не применяются. systemctl status при этом честно предупреждает: «Warning: The unit file changed on disk».

Чтобы не править чужие unit-файлы из пакета (их перезапишет apt upgrade), используют drop-in:

sudo systemctl edit nginx      # создаст /etc/systemd/system/nginx.service.d/override.conf
systemctl cat nginx            # показать итоговый unit со всеми override
systemctl show nginx -p Restart -p MemoryMax   # эффективные значения свойств

Ещё две директивы, которые стоят рядом с Restart=on-failure:

TimeoutStopSec=30              # сколько ждать graceful shutdown до SIGKILL
MemoryMax=512M                 # жёсткий лимит памяти; при превышении сработает OOM

TimeoutStopSec подбирают под самый долгий запрос сервиса: если он меньше, systemd прибьёт процесс на середине обработки.

Фоновые процессы

Здесь только минимум для дежурных задач; полный job control - Ctrl+Z, bg, jobs -l, disown и почему для долгих деплоев берут tmux, а не nohup - разобран в уроке про терминал.

./script.sh &                  # запустить в фоне
nohup ./script.sh &            # в фоне + не умрёт при закрытии SSH
setsid ./script.sh             # новая сессия: процесс вообще не привязан к терминалу
jobs                           # список фоновых задач
fg                             # вернуть в передний план

Способов «оставить работать на сервере» несколько, и у каждого своя цена:

СпособПереживёт обрыв SSHАвтозапуск после rebootПерезапуск после паденияГде логи
./app &нет, придёт SIGHUPнетнетв терминале, теряются
nohup ./app &данетнетnohup.out рядом
setsid ./appда, своя сессиянетнеткуда перенаправишь
tmuxда, плюс можно вернуться и посмотретьнетнетбуфер сессии
systemd unitдадада (Restart=)journald, ротация из коробки

Из таблицы виден вывод: nohup и setsid годятся для разового запуска (миграция, разовый импорт), tmux - когда нужно вернуться и посмотреть вывод, а всё, что должно работать постоянно, живёт под systemd. «Приложение в проде под nohup» означает: после первой же перезагрузки сервера сайт лежит, и никто не знает почему.

/proc/PID: процесс как набор файлов

ps и top не выдумывают данные - они читают виртуальную файловую систему /proc. Иногда проще сходить туда напрямую:

cat /proc/1234/cmdline | tr '\0' ' '   # полная команда запуска (аргументы через \0)
sudo cat /proc/1234/environ | tr '\0' '\n'   # переменные окружения процесса
ls -l /proc/1234/cwd                    # рабочий каталог (симлинк)
ls -l /proc/1234/exe                    # какой бинарник реально запущен
ls -l /proc/1234/fd | head              # открытые файлы и сокеты
cat /proc/1234/limits                   # лимиты: open files, stack, processes
grep -E 'VmRSS|Threads|State' /proc/1234/status   # память, число потоков, состояние

Три прикладных сценария.

Проверить, с каким конфигом реально запустился сервис. Не тем, что лежит в .env, а тем, что процесс получил. /proc/PID/environ покажет истину, если сервис читает настройки из окружения (как в уроке про переменные окружения в Go).

Найти, куда пропало место на диске. df -h показывает 100%, du -sh /var/log - пусто. Значит, файл удалили, но процесс держит дескриптор открытым, и место не освободится до перезапуска:

sudo lsof | grep deleted
# backend 1234 appuser 3w REG 8,1 21474836480 /var/log/app.log (deleted)

Убедиться, что сервис упёрся в лимит дескрипторов. too many open files в логах плюс cat /proc/1234/limits покажет Max open files 1024 - лечится LimitNOFILE=65535 в unit-файле.

`/proc/PID/environ` доступен владельцу процесса и root. Это значит: токен, переданный через переменную окружения, виден любому, кто получил доступ от того же пользователя, и попадает в дампы. Секреты в env лучше, чем секреты в git, но хуже, чем файл с правами `600`, который сервис читает при старте, см. урок про [права доступа](./03-files-permissions.md).

OOM killer: кто и почему умирает первым

Если память в системе кончилась, ядро не отдаёт ошибку выделения - оно выбирает жертву и убивает её SIGKILL. Выбор делается по oom_score, и он не равен «кто последний запросил память»: чем больше процесс занимает, тем выше его счёт. Поэтому первым обычно падает не виновник утечки, а самый крупный процесс на машине, то есть база данных или ваш сервис.

dmesg -T | grep -i "killed process"       # факт срабатывания и жертва
journalctl -k --since "1 hour ago" | grep -i oom
cat /proc/1234/oom_score                   # текущий счёт процесса
cat /proc/1234/oom_score_adj               # поправка от -1000 до 1000

Признаки, по которым OOM опознают, не заходя в dmesg: сервис исчез без записи в своих логах, systemctl status показывает Result: oom-kill или код 137, а в контейнерах docker inspect даёт "OOMKilled": true.

# В unit-файле: защитить критичный сервис и подставить второстепенный
OOMScoreAdjust=-500       # реже становится жертвой
MemoryMax=512M            # лимит на сам сервис: упадёт он, а не сосед
Первый - общесистемный, когда память кончилась на хосте. Второй - лимит cgroup: контейнер получил `--memory=256m`, вылез за него, и ядро убило процесс **внутри** контейнера, хотя на хосте памяти полно. Второй случай коварнее: хост выглядит здоровым, метрики хоста чистые, а сервис перезапускается каждые полчаса. Смотри лимиты в compose-файле, см. урок про [docker compose](../docker/06-docker-compose.md).

nice и ionice: не мешать основному сервису

Классический инцидент: ночной бэкап базы упирается в диск, и latency API вырастает втрое, хотя CPU почти свободен. Проблема не в процессоре, а в очереди к диску. Приоритеты задаются отдельно для CPU и для ввода-вывода:

nice -n 10 ./heavy-report.sh        # запустить с пониженным приоритетом CPU (-20..19)
renice -n 10 -p 1234                # понизить уже запущенному
ionice -c 3 pg_dump mydb > dump.sql # класс 3 (idle): диск отдаётся только когда он свободен
ionice -c 2 -n 7 tar czf backup.tar.gz /var/www   # best-effort, самый низкий приоритет
ionice -p 1234                      # посмотреть текущий класс процесса

Правило распределения: nice помогает, когда узкое место - CPU, ionice - когда диск. Понижать приоритет фоновой задаче почти всегда безопаснее, чем повышать основному сервису: повышение требует root и легко приводит к тому, что «важный» процесс вытесняет всё, включая ssh.

В systemd те же настройки живут в unit-файле бэкапа, и это правильнее, чем оборачивать команду в скрипте:

Nice=10
IOSchedulingClass=idle
CPUWeight=20              # доля CPU относительно других сервисов (по умолчанию 100)

Мини-задание

  • Выполни ps aux | grep -c "" - сколько процессов в системе?
  • Найди, кто слушает порт 22 (SSH): ss -tlnp | grep 22
  • Посмотри статус любого сервиса: systemctl status sshd
  • Прочитай последние логи: journalctl -u sshd --no-pager | tail -10
  • Построй дерево процессов: ps -ef --forest | head -30 - найди, кто чей родитель
  • Найди зомби: ps aux | awk '$8 ~ /Z/ {print $2, $11}' (если пусто - это хорошая новость)
  • Посмотри команду запуска любого сервиса: cat /proc/1/cmdline | tr '\0' ' '
  • Проверь, срабатывал ли OOM killer: dmesg -T | grep -i "killed process"
  • Запусти nice -n 19 sleep 60 & и найди процесс в ps aux по букве N в колонке STAT

Итог

  • ps aux + grep - найти процесс. kill - завершить. kill -9 - в крайнем случае.
  • ss -tlnp - узнать, кто слушает порты. Это первая команда при «port already in use».
  • systemctl - управление сервисами. journalctl - их логи.

Типичная ошибка

Запускать приложение через ./backend & на сервере. При закрытии SSH сессия умрёт - и процесс вместе с ней. Используй systemd-сервис или хотя бы nohup.

Мини-практика

Запусти php -S localhost:9090 & (или npx serve -l 9090 &). Найди этот процесс через ps aux | grep 9090. Узнай его PID. Проверь порт через ss -tlnp | grep 9090. Завершите процесс через kill <PID>. Убедись, что порт освободился.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.