Процессы, порты и 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 # кто их родитель
Откуда зомби берутся в бэкенде: приложение вызывает внешнюю утилиту (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);
Управление процессами
kill 1234 # попросить процесс завершиться (SIGTERM)
kill -9 1234 # убить принудительно (SIGKILL)
killall nginx # завершить все процессы с именем nginx
Сигналов больше двух, и половина повседневных задач решается не через рестарт:
| Сигнал | Номер | Перехватывается | Типичное применение |
|---|---|---|---|
| SIGTERM | 15 | да | вежливая просьба завершиться, дефолт kill и docker stop |
| SIGINT | 2 | да | то же, что Ctrl+C |
| SIGHUP | 1 | да | «перечитай конфиг»: kill -HUP $(pidof nginx) вместо рестарта |
| SIGUSR1 / SIGUSR2 | 10 / 12 | да | что решит приложение; nginx по USR1 переоткрывает лог-файлы после logrotate |
| SIGSTOP / SIGCONT | 19 / 18 | нет / да | заморозить и продолжить процесс, не убивая |
| SIGKILL | 9 | нет | последний аргумент; обработчик не вызывается |
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-массив, см. сборку образа по шагам.
Кто занял порт?
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 # запустить
Правки 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-файле.
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 # лимит на сам сервис: упадёт он, а не сосед
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>. Убедись, что порт освободился.