Операционные системы Командная строка и инструментарий Linux: shell, утилиты, процессы, пакеты
0%

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

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

Есть распространённое заблуждение, что командная строка — это «устаревший интерфейс для тех, кому лень поставить GUI». Это ровно наоборот. Графический интерфейс — это фиксированный набор заранее предусмотренных действий. Командная строка — это язык программирования, в котором любая установленная в системе программа является функцией, а вывод одной функции можно подать на вход другой. GUI даёт вам меню; shell даёт вам композицию.

Практическое следствие: то, что в GUI требует специальной кнопки (а её обычно нет), в shell собирается за тридцать секунд из четырёх утилит, каждая из которых написана в 1979 году и с тех пор не менялась. Найти все процессы, открывшие удалённый файл, посчитать распределение кодов ответа по логу за последний час, отсортировать контейнеры по потреблению памяти — ничего из этого никто специально не программировал. Оно получается композицией.

Но у этой мощи есть цена. Shell — язык с чрезвычайно необычной семантикой: он подставляет, а не вычисляет; результат подстановки снова разбирается как код; ошибки по умолчанию игнорируются. Программист, пришедший из Python или Go и пишущий bash «по аналогии», гарантированно напишет скрипт, который однажды удалит не тот каталог. Поэтому эта статья — не сборник рецептов, а разбор механики: что shell делает с вашей строкой, какие системные вызовы за этим стоят и где именно проходят острые края.

Статья продолжает Linux для программиста и опирается на устройство процессов из процессов и планировщиков, на файловые дескрипторы из системных вызовов и IPC и на cgroups из безопасности и изоляции. Если что-то из перечисленного проскочило мимо — читайте эту статью как практическое подтверждение того материала.

Часть I. Что такое shell на самом деле

Три системных вызова, из которых собрано всё

Когда вы вводите ls -l /etc, происходит не «выполнение команды», а вполне конкретная последовательность:

  1. Shell разбирает строку в список слов: ["ls", "-l", "/etc"].
  2. Проверяет, не встроенная ли это команда (cd, export, read — их около полусотни в bash). ls не встроенная.
  3. Ищет ls в каталогах из $PATH слева направо, кэшируя результат в хеш-таблице (hash -r её сбрасывает).
  4. Вызывает fork(2) — создаёт свою копию.
  5. В копии вызывает execve("/usr/bin/ls", argv, envp) — заменяет образ процесса на новый, сохраняя pid и открытые дескрипторы.
  6. В родителе вызывает waitpid(2) — спит, пока ребёнок не завершится, и забирает код возврата в $?.

Всё. Никакой магии. Убедиться можно за одну команду:

$ strace -f -e trace=execve,clone,wait4 -o /tmp/tr.log bash -c 'ls -l /etc | head -3'
$ grep -E 'execve|clone|wait4' /tmp/tr.log
4711 execve("/usr/bin/bash", ["bash", "-c", "ls -l /etc | head -3"], 0x7ffd...) = 0
4711 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|SIGCHLD, ...) = 4712
4711 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|SIGCHLD, ...) = 4713
4712 execve("/usr/bin/ls", ["ls", "-l", "/etc"], 0x55a... /* 42 vars */) = 0
4713 execve("/usr/bin/head", ["head", "-3"], 0x55a... /* 42 vars */) = 0
4711 wait4(-1, ...) = 4713

Обратите внимание: clone вызван дважды до обоих execve — оба звена конвейера стартуют параллельно, а не по очереди. Это важнейшее свойство, к которому мы вернёмся.

Понимание «shell = fork + exec» сразу объясняет вещи, которые иначе выглядят произволом:

  • Почему cd — встроенная команда. Рабочий каталог — свойство процесса (chdir(2) меняет fs->pwd в структуре задачи). Внешняя программа сменила бы каталог себе и умерла. Поэтому cd, export, ulimit, exec, read обязаны быть встроенными.
  • Почему переменные не «наследуются наверх». export помечает переменную для копирования в envp при execve. Наследование идёт строго вниз по дереву процессов, обратного пути нет физически.
  • Почему sudo command > /root/file не работает. Перенаправление делает shell — до того, как запустится sudo, и от имени обычного пользователя. Нужно sudo sh -c 'command > /root/file' или command | sudo tee /root/file.
  • Почему скрипт с #!/bin/bash не может «вернуть» вам каталог. Он выполняется в дочернем процессе. Нужен source script.sh (он же . script.sh), который читает файл в текущем шелле без fork.

Встроенные, функции, алиасы, внешние: порядок разрешения

Bash ищет имя команды в жёстком порядке: алиасы → функции → встроенные → $PATH. Проверить, чем именно является имя, — type:

$ type -a ls echo [ time
ls is aliased to `ls --color=auto'
ls is /usr/bin/ls
echo is a shell builtin
echo is /usr/bin/echo
[ is a shell builtin
[ is /usr/bin/[
time is a shell keyword

Заметьте: echo существует и как встроенная, и как файл — и они ведут себя по-разному (/usr/bin/echo -e печатает -e буквально, встроенная — интерпретирует). Это классический источник расхождений между интерактивной сессией и скриптом с #!/bin/sh. Универсальный совет: в скриптах пишите printf, а не echo — поведение echo с флагами и обратными слешами не определено POSIX.

time — вообще ключевое слово, а не команда, поэтому time cmd1 | cmd2 меряет весь конвейер, а /usr/bin/time cmd1 | cmd2 — только первое звено.

Часть II. Раскрытия: главный источник багов

Это самая важная и самая недооценённая часть. Shell не «передаёт строку программе». Он выполняет над строкой семь стадий преобразований в фиксированном порядке, и результат каждой стадии участвует в следующих. Ошибка почти всегда в том, что человек не ожидал очередной стадии.

Красные стадии — те, что происходят после подстановки значения и применяются к результату. Отсюда правило, которое стоит выжечь на подкорке:

Каждое раскрытие переменной или команды должно быть в двойных кавычках, кроме случаев, когда вы сознательно хотите разбиения и глоббинга.

Смотрим, что бывает без них:

$ mkdir -p /tmp/demo && cd /tmp/demo
$ touch 'my report.txt' 'a*b.txt' normal.txt

$ f='my report.txt'
$ ls $f            # без кавычек: разбилось на два слова
ls: cannot access 'my': No such file or directory
ls: cannot access 'report.txt': No such file or directory
$ ls "$f"          # правильно
'my report.txt'

$ pattern='*.txt'
$ echo $pattern    # значение переменной снова прошло глоббинг!
a*b.txt my report.txt normal.txt
$ echo "$pattern"
*.txt

Второй пример особенно коварен: содержимое переменной повторно интерпретируется как glob. Классическая продакшн-авария — скрипт бэкапа, где TARGET=$1 и rm -rf $TARGET/* вызывают с пустым первым аргументом: rm -rf /*.

Разбор реальных ловушек

Ловушка 1: цикл по строкам файла.

# ПЛОХО: разобьёт строки по пробелам, съест обратные слеши, потеряет последнюю
# строку без перевода в конце
for line in $(cat file); do process "$line"; done

# ХОРОШО
while IFS= read -r line || [[ -n $line ]]; do
    process "$line"
done < file

IFS= (пусто) отключает обрезку пробелов по краям, -r запрещает интерпретацию обратных слешей, || [[ -n $line ]] дочитывает последнюю строку без завершающего \n.

Ловушка 2: имена файлов с переводом строки. В Linux имя файла — это любая последовательность байт без / и \0. Перевод строки в имени легален. Поэтому find . -type f | while read f ломается на злонамеренном (или просто криво выгруженном) имени. Правильно — разделитель \0:

# Единственный полностью безопасный способ обойти файлы
find . -type f -print0 | while IFS= read -r -d '' f; do
    printf 'обрабатываю: %s\n' "$f"
done

# Или без промежуточного шелла вообще
find . -type f -exec sha256sum {} +     # + вместо \; батчит аргументы

Ловушка 3: ls в скриптах. ls форматирует вывод для человека: экранирует спецсимволы, меняет колонки в зависимости от того, терминал ли на выходе. Парсить его нельзя никогда. Для перебора файлов есть glob (for f in *.txt), для метаданных — stat, find -printf.

Ловушка 4: пустой glob. Если *.txt ничего не нашёл, bash по умолчанию оставляет буквальную строку *.txt, и цикл выполнится один раз с несуществующим файлом. Лечится shopt -s nullglob (пустой список) или проверкой [[ -e $f ]] внутри.

Ловушка 5: арифметика с ведущим нулём. $((08)) — ошибка: bash считает восьмеричным. Часы и минуты из date +%H регулярно роняют скрипты в 8 и 9 часов утра. Лечится $((10#$H)).

Часть III. Дескрипторы, перенаправления и конвейеры

Перенаправления — это dup2, а не «запись в файл»

Перенаправление выполняет shell в дочернем процессе между fork и exec. Механика: открыть файл, получить дескриптор, скопировать его на нужный номер через dup2(2), закрыть лишнее. Программа затем просто пишет в дескриптор 1, ничего не зная о перенаправлении.

Из этого следует главное правило порядка:

# Слева направо, каждая операция — dup2 на момент выполнения

cmd > file 2>&1     # 1 → file; затем 2 → копия текущего 1 (= file). Оба в файл.
cmd 2>&1 > file     # 2 → копия текущего 1 (= терминал!); затем 1 → file.
                    # stderr остался на терминале. Почти всегда это баг.
cmd &> file         # bash-сокращение для первого варианта; в POSIX sh нет

Дескрипторы не ограничены тройкой 0/1/2 — их можно открывать явно:

# Открыть файл на чтение-запись как fd 3, работать, закрыть
exec 3<> /tmp/state.txt
read -r line <&3            # прочитать из него
printf 'обновлено\n' >&3    # записать
exec 3>&-                   # закрыть

# Сохранить оригинальный stdout, чтобы временно всё заглушить и вернуть
exec 9>&1
exec 1>/dev/null
noisy_command
exec 1>&9 9>&-

Отдельно — подстановка процессов (<(...), >(...)), фича bash/zsh/ksh, которой нет в POSIX sh. Она даёт путь вида /dev/fd/63, за которым стоит анонимный канал. Это позволяет скармливать вывод команды туда, где программа требует именно файл:

# diff двух команд без временных файлов
$ diff <(rpm -qa | sort) <(ssh other-host 'rpm -qa | sort')

# посмотреть, что это такое
$ echo <(true)
/dev/fd/63
$ ls -l /proc/self/fd/63    # изнутри команды
lr-x------ 1 user user 64 Jul 16 10:00 63 -> 'pipe:[482913]'

Важное ограничение: /dev/fd/63 — это канал, а не файл. Программа, которая делает lseek (перематывает ввод) или открывает файл дважды, сломается. sort работает, tar -tf — как повезёт.

Here-document и here-string:

# Here-doc с подстановкой переменных
cat <<EOF > /etc/nginx/conf.d/app.conf
server { listen ${PORT}; }
EOF

# Кавычки вокруг метки ОТКЛЮЧАЮТ подстановку — критично для скриптов и awk
cat <<'EOF' > deploy.sh
echo "$HOME остаётся literal, $(date) не выполнится"
EOF

# <<- срезает ведущие ТАБУЛЯЦИИ (не пробелы!) — можно делать отступы
# <<< — here-string, скармливает строку в stdin
$ tr a-z A-Z <<< "привет world"
привет WORLD

Конвейер как параллельная программа

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

Анатомия конвейера: дескрипторы, буферы, SIGPIPE и код возврата

Отсюда вытекает практика:

Конвейер даёт параллелизм бесплатно. zcat huge.gz | grep pattern | wc -l использует три ядра. Распаковка не ждёт грепа — она пишет в буфер, пока там есть место.

Размер буфера конечен и настраиваем. По умолчанию 64 КиБ (16 страниц), лимит для непривилегированного пользователя — /proc/sys/fs/pipe-max-size (обычно 1 МиБ). Увеличить конкретному каналу можно из программы через fcntl(fd, F_SETPIPE_SZ, size), а на командной строке — утилитой pv -B. Слишком маленький буфер при мелких записях = много переключений контекста; помогает stdbuf -oL / -o1M для управления буферизацией libc.

Буферизация libc ломает интерактивность. Библиотека C выбирает режим буферизации по типу вывода: терминал → построчно, канал → блоками по 4 КиБ. Поэтому tail -f log | grep ERROR может молчать минутами: grep честно получил данные, но его собственный вывод копится в буфере. Лечения два:

$ tail -f app.log | grep --line-buffered ERROR       # флаг самой утилиты
$ tail -f app.log | stdbuf -oL grep ERROR            # общий способ, подменяет
                                                     # режим через LD_PRELOAD
$ unbuffer ./program | tee out.log                   # из пакета expect: даёт
                                                     # программе псевдотерминал

Заметьте: stdbuf не работает с программами со статической линковкой и с теми, кто явно вызывает setvbuf (например, сам tail). unbuffer работает всегда, потому что обманывает программу псевдотерминалом.

stderr не идёт в конвейер. make 2>&1 | grep error — обязательное 2>&1, иначе ошибки летят мимо.

Код возврата берётся от последней команды. Отсюда set -o pipefail и массив PIPESTATUS:

$ false | true; echo "$?"                  # 0 — успех, хотя false упал
0
$ false | true; echo "${PIPESTATUS[@]}"    # коды всех звеньев
1 0
$ set -o pipefail
$ false | true; echo "$?"
1

Мини-задача: посчитать топ IP по логу

Классика, которая показывает силу композиции. Дан access-лог nginx на 12 миллионов строк.

$ awk '$9 == 500 {print $1}' access.log | sort | uniq -c | sort -rn | head -5
  84213 10.0.3.44
   1907 10.0.3.51
    233 192.168.7.9
    ...

# То же, но в 6 раз быстрее: локаль + параллельная сортировка + буфер
$ LC_ALL=C awk '$9 == 500 {print $1}' access.log \
    | LC_ALL=C sort -S 2G --parallel=8 \
    | uniq -c | sort -rn | head -5

Почему LC_ALL=C даёт кратное ускорение: в UTF-8 локали sort и grep применяют правила сравнения Unicode (collation) — это посимвольная нормализация вместо простого memcmp. Для ASCII-данных разница на порядок. Побочный эффект: меняется порядок и семантика диапазонов ([a-z] в C-локали не включает B). Правило: для машинных данных всегда LC_ALL=C, для пользовательских — родная локаль.

Ещё быстрее — вообще без сортировки, одним проходом awk (память O(количество уникальных IP), время O(n)):

$ LC_ALL=C awk '$9 == 500 {c[$1]++} END {for (k in c) print c[k], k}' access.log \
    | sort -rn | head -5

Часть IV. Процессы, сигналы и job control

Иерархия: сессия, группа, терминал

Ctrl+C не «прерывает программу». Он посылает SIGINT всей переднеплановой группе процессов управляющего терминала. Понимание этой трёхуровневой иерархии объясняет добрую половину практических вопросов про фон, ssh и демонов.

Сессия, группы процессов и управляющий терминал

Проверяем на живой системе:

$ sleep 300 | cat &
[1] 5210
$ ps -o pid,ppid,pgid,sid,tpgid,stat,comm
    PID    PPID    PGID     SID   TPGID STAT COMMAND
   4100    4099    4100    4100    5310 Ss   bash
   5209    4100    5209    4100    5310 S    sleep
   5210    4100    5209    4100    5310 S    cat
   5310    4100    5310    4100    5310 R+   ps

sleep и cat разделяют PGID 5209 — это одна job. Плюс в колонке STAT у ps означает «в переднеплановой группе»; у фоновых его нет. TPGID — какая группа сейчас на переднем плане с точки зрения терминала.

Жизненный цикл job — конечный автомат:

Как правильно запустить долгий процесс

Четыре разных ответа для четырёх разных задач:

# 1. Разовая задача, вы уйдёте из ssh: nohup игнорирует SIGHUP, лог в файл
$ nohup ./import.sh > import.log 2>&1 &
$ disown            # ещё и вычеркнуть из таблицы job'ов

# 2. Нужно вернуться и посмотреть/поуправлять — мультиплексор терминалов
$ tmux new -s import
  ./import.sh
  # Ctrl+B, затем D — отсоединиться
$ tmux attach -t import

# 3. Нужны лимиты, логи в journald, автоперезапуск — временный systemd-юнит
$ systemd-run --unit=import --property=MemoryMax=2G \
              --property=CPUQuota=200% ./import.sh
$ journalctl -u import -f
$ systemctl status import

# 4. Полностью отвязать от терминала прямо сейчас
$ setsid ./daemon.sh < /dev/null > /var/log/daemon.log 2>&1 &

systemd-run — недооценённый инструмент. Он даёт вам одноразовый cgroup со всеми ограничениями, не создавая файл юнита. Про сами юниты — в системах инициализации.

Сигналы на практике

$ kill -l | head -3
 1) SIGHUP   2) SIGINT   3) SIGQUIT  4) SIGILL   5) SIGTRAP
 6) SIGABRT  7) SIGBUS   8) SIGFPE   9) SIGKILL 10) SIGUSR1
...

$ kill 1234           # по умолчанию SIGTERM (15) — «попроси завершиться»
$ kill -9 1234        # SIGKILL — ядро убивает, обработчик не вызывается
$ kill -HUP $(pidof nginx)   # многие демоны по HUP перечитывают конфиг
$ pkill -f 'python.*worker'  # по полной командной строке — осторожно с -f!
$ pgrep -a -f worker         # СНАЧАЛА посмотреть, кого убьёте

Правила, которые стоит соблюдать:

  • kill -9 — не «сильнее», а «неправильнее». Процесс не сбросит буферы, не удалит pid-файл, не закроет транзакцию, не снимет lock. Всегда сначала TERM, подождать, потом KILL. И: kill -9 не помогает против процесса в состоянии D (непрерываемый сон в ядре — обычно зависший NFS или сбойный диск), потому что процесс просто не доходит до обработки сигналов.
  • pkill -f смотрит на всю командную строку. Строка pkill -f test убьёт и ваш pytest, и чужой latest-backup.sh. Всегда репетируйте через pgrep -a -f.
  • В скриптах нужен trap для уборки:
#!/usr/bin/env bash
set -Eeuo pipefail

tmpdir=$(mktemp -d)
cleanup() {
    local code=$?
    rm -rf -- "$tmpdir"
    (( code )) && printf 'скрипт упал с кодом %d на строке %d\n' "$code" "$LINENO" >&2
    exit "$code"
}
trap cleanup EXIT           # сработает при любом выходе, включая ошибку
trap 'exit 130' INT         # Ctrl+C → корректный код 128+2

trap ... EXIT — единственная надёжная точка уборки; она срабатывает и при set -e, и при нормальном завершении. Обратите внимание на rm -rf -- "$tmpdir": двойной дефис отсекает интерпретацию имени как флага.

Часть V. Утилиты как алгебра над текстом

Философия Unix, сформулированная Дугом Макилроем в 1978-м: «Пишите программы, делающие одну вещь и делающие её хорошо. Пишите программы, работающие вместе. Пишите программы, обрабатывающие текстовые потоки, потому что это универсальный интерфейс».

Текст как универсальный интерфейс — это гениальный и одновременно спорный выбор. Гениальный, потому что позволяет соединять что угодно с чем угодно без предварительной договорённости. Спорный, потому что структура теряется и её приходится восстанавливать регулярками. PowerShell пошёл другим путём (объекты вместо текста) — и об этом ниже.

Классификация утилит по типу операции — по сути это операторы реляционной алгебры:

Операция Утилиты Пример
Фильтр строк (селекция) grep, awk, sed -n, rg grep -E '5[0-9]{2}'
Проекция колонок cut, awk, jq awk '{print $1, $7}'
Сортировка sort sort -t: -k3,3n
Агрегация uniq -c, awk, datamash uniq -c
Соединение (join) join, awk, comm join -t, -1 2 -2 1 a b
Трансформация tr, sed, rev, iconv tr -d '\r'
Разветвление tee, xargs tee >(gzip > a.gz)
Ограничение head, tail, sed -n '10,20p' head -c 1M

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

awk — не «утилита для колонок», а полноценный язык с ассоциативными массивами, работающий одним потоком. Многие задачи, которые пишут на Python за 30 строк, здесь — одна строка:

# Суммировать байты ответа по коду статуса, вывести отсортированно
$ awk '{bytes[$9] += $10; cnt[$9]++}
       END {for (c in bytes) printf "%s\t%d req\t%.1f MB\n", c, cnt[c], bytes[c]/1048576}' \
      access.log | sort -k1,1n
200	9481203 req	40213.7 MB
404	118422 req	31.2 MB
500	84213 req	9.8 MB

# Строки между двумя маркерами
$ awk '/BEGIN CERT/,/END CERT/' bundle.pem

# CSV с разделителем и пропуском заголовка
$ awk -F, 'NR > 1 && $3 > 100 {print $1}' data.csv

sed для потоковых замен, но помните ограничения: работает построчно, регулярки POSIX (в GNU — с расширениями -E), многострочные операции требуют работы с hold space и быстро становятся нечитаемыми. Если задача переросла s/// — берите awk или Python.

jq для JSON — обязателен в мире, где половина API отвечает JSON:

$ kubectl get pods -o json | jq -r '
    .items[]
    | select(.status.phase != "Running")
    | "\(.metadata.namespace)/\(.metadata.name)\t\(.status.phase)"'
prod/api-7d9f-x2k    CrashLoopBackOff
prod/worker-5b1-qq   Pending

# Флаг -r снимает кавычки; --arg безопасно передаёт переменную шелла
$ jq --arg ns prod '.items[] | select(.metadata.namespace == $ns)' pods.json

xargs — мост между «список на stdin» и «аргументы командной строки», плюс дешёвый параллелизм:

# Безопасный вариант: -0 (NUL-разделители) и -r (не запускать на пустом вводе)
$ find . -name '*.log' -mtime +30 -print0 | xargs -0 -r rm --

# Параллельно по 8 процессов, по одному аргументу на вызов
$ cat urls.txt | xargs -P 8 -n 1 curl -sS -o /dev/null -w '%{http_code} %{url_effective}\n'

# {} — плейсхолдер для подстановки в середину команды
$ ls *.wav | xargs -P 4 -I{} ffmpeg -i {} -c:a libopus {}.opus

Важно: -r (--no-run-if-empty) — расширение GNU, в BSD xargs его нет (там пустой ввод и так не запускает команду — а вот GNU без -r запустит). Это одно из тех расхождений, что ломают скрипты при переносе на macOS.

Современные замены, которые действительно лучше: rg (ripgrep) вместо grep -r — на порядок быстрее за счёт параллельного обхода, уважения .gitignore и SIMD-поиска; fd вместо find с человеческим синтаксисом; bat вместо cat для чтения; dust/ncdu вместо du. В скриптах, которые пойдут на чужие машины, оставайтесь на POSIX-инструментах — их наличие гарантировано.

Часть VI. Ресурсы, лимиты и наблюдение за процессами

Прикладной программист сталкивается с лимитами обычно в форме загадочной ошибки: too many open files, cannot allocate memory при наличии свободной памяти, fork: retry: Resource temporarily unavailable. Все они — про rlimits и cgroups.

$ ulimit -a
real-time non-blocking time  (microseconds, -R) unlimited
core file size              (blocks, -c) 0
data seg size               (kbytes, -d) unlimited
scheduling priority                 (-e) 0
file size                   (blocks, -f) unlimited
pending signals                     (-i) 127590
max locked memory           (kbytes, -l) 8192
max memory size             (kbytes, -m) unlimited
open files                          (-n) 1024        # ← главный подозреваемый
pipe size                (512 bytes, -p) 8
POSIX message queues         (bytes, -q) 819200
stack size                  (kbytes, -s) 8192        # ← глубина рекурсии
cpu time                   (seconds, -t) unlimited
max user processes                  (-u) 127590
virtual memory              (kbytes, -v) unlimited
file locks                          (-x) unlimited

Лимиты бывают мягкие и жёсткие: мягкий процесс может поднять сам до жёсткого, жёсткий понижается необратимо и поднимается только root. Наследуются через fork и переживают exec.

# Лимиты конкретного работающего процесса — прямо из ядра
$ cat /proc/$(pgrep -o nginx)/limits | grep -E 'Limit|open files'
Limit                     Soft Limit  Hard Limit  Units
Max open files            1024        524288      files

# Сколько дескрипторов реально открыто
$ ls /proc/$(pgrep -o nginx)/fd | wc -l
847

# Кто именно их держит
$ lsof -p $(pgrep -o nginx) | awk '{print $5}' | sort | uniq -c | sort -rn
    612 IPv4
    198 REG
     31 sock

Для сервисов ставить лимиты через ulimit в скрипте запуска бессмысленно — systemd стартует их сам. Правильное место — юнит:

[Service]
LimitNOFILE=65536
LimitNPROC=4096
MemoryMax=4G
MemoryHigh=3G          # мягкий порог: включает агрессивный reclaim
CPUQuota=200%          # 2 ядра
TasksMax=512
OOMPolicy=stop

MemoryMax (cgroup v2 memory.max) — жёсткий потолок: при превышении срабатывает cgroup-OOM и убивает процесс внутри группы, не трогая систему. Это принципиально лучше глобального OOM killer, который выбирает жертву эвристикой по oom_score и вполне может застрелить базу данных вместо виноватого воркера.

# Текущее потребление cgroup сервиса
$ cat /sys/fs/cgroup/system.slice/myapp.service/memory.current
2147483648
$ cat /sys/fs/cgroup/system.slice/myapp.service/memory.max
4294967296
# Сколько раз группа упиралась в потолок и сколько было OOM
$ grep -E 'max|oom_kill' /sys/fs/cgroup/system.slice/myapp.service/memory.events
max 1423
oom_kill 2

Прочие полезные рычаги:

$ nice -n 19 ./batch-job.sh        # понизить приоритет CPU (диапазон -20..19)
$ renice -n 10 -p 4711            # изменить у работающего
$ ionice -c 3 -p 4711             # класс idle для дискового ввода-вывода
$ taskset -c 2,3 ./latency-app    # прибить к ядрам 2 и 3
$ chrt -f 50 ./realtime-app       # SCHED_FIFO с приоритетом 50 (нужен CAP_SYS_NICE)

Про то, как всё это выглядит со стороны планировщика, — в процессах и планировщиках; детальная диагностика (strace, perf, eBPF) — тема следующей статьи.

Часть VII. Пакетные менеджеры

Задача пакетного менеджера звучит просто — «поставить программу», — но за ней стоит нетривиальная работа: разрешение зависимостей (NP-полная задача в общем случае, современные менеджеры используют SAT-решатели), атомарность обновления, обработка изменённых конфигов, целостность через подписи, откат.

Практика: Debian/Ubuntu

$ sudo apt update                       # обновить индексы из sources.list
$ apt list --upgradable                 # что изменится
$ sudo apt install nginx                # с зависимостями
$ apt show nginx                        # метаданные
$ apt-cache rdepends --installed libssl3   # кто зависит от пакета
$ dpkg -S /usr/sbin/nginx               # какому пакету принадлежит файл
nginx-core: /usr/sbin/nginx
$ dpkg -L nginx-core | head             # что пакет положил на диск
$ dpkg -V nginx-core                    # проверить изменённые файлы (по хешам)
$ apt-mark hold nginx                   # заморозить версию
$ sudo apt install nginx=1.24.0-1       # конкретная версия
$ zcat /usr/share/doc/nginx/changelog.Debian.gz | head

Ключевое различие, о которое спотыкаются: apt remove оставляет конфиги в /etc, apt purge удаляет всё. Осиротевшие зависимости убирает apt autoremove — и он же периодически сносит нужное, если пакет был поставлен как зависимость. Пометить явно: apt-mark manual pkg.

Практика: RHEL/Fedora

$ sudo dnf install nginx
$ dnf provides /usr/sbin/nginx          # аналог dpkg -S, работает и для неустановленных
$ rpm -qf /usr/sbin/nginx               # для установленных
$ rpm -qa --last | head                 # что ставили последним
$ rpm -V nginx                          # верификация: S=size M=mode 5=md5 T=mtime
$ dnf history                           # журнал транзакций
$ sudo dnf history undo 42              # откат транзакции целиком — сильная сторона dnf
$ dnf repoquery --requires --resolve nginx

RPM хранит конфиги, изменённые пользователем, как .rpmnew/.rpmsave; deb спрашивает интерактивно (dpkg --force-confold в автоматизации). Разница в поведении регулярно ломает Ansible-плейбуки, написанные под одну семью.

Nix: другая модель

Nix решает проблему в корне: пакет ставится в /nix/store/<хеш>-имя-версия, где хеш вычисляется от всех входов сборки — исходников, компилятора, флагов, всех зависимостей рекурсивно. Следствия: две версии одной библиотеки живут рядом без конфликта; обновление атомарно (переключение символической ссылки); откат мгновенный; окружение воспроизводимо побайтово.

$ nix-shell -p python312 postgresql_16 --run 'python --version'
Python 3.12.7
# Ничего не установилось в систему: временное окружение с точными версиями

$ nix profile install nixpkgs#ripgrep
$ nix profile rollback              # откат к предыдущему поколению
$ ls -l /nix/store | head -2
dr-xr-xr-x 1 root root  4096 Jan  1  1970 0zr4kg8v9m3q...-ripgrep-14.1.0

Цена — крутая кривая обучения, свой функциональный язык и заметный расход диска. Но для воспроизводимых сборочных окружений это лучшее, что есть. Подробнее — nixos.org/manual и статья Дольстры «The Purely Functional Software Deployment Model» (2006), где модель впервые описана.

Правила гигиены

  • Не мешайте системный менеджер с pip install --user / npm -g глобально. Питон системы принадлежит дистрибутиву; sudo pip install может сломать apt (в Debian 12+ это прямо запрещено флагом EXTERNALLY-MANAGED). Используйте venv, pipx, --prefix=$HOME/.local.
  • /usr/local — для вас, /usr — для менеджера. Это правило FHS. Всё, что вы собрали руками, ставьте с --prefix=/usr/local или в /opt/имя, иначе следующее обновление всё затрёт.
  • Порядок в $PATH определяет победителя. echo "$PATH" | tr : '\n' и type -a python3 показывают, какой бинарь выиграет. Классическая загадка «почему в скрипте другая версия» — потому что неинтерактивный shell читает другие rc-файлы.
  • Внешние репозитории пиннуйте. Сторонний репозиторий с более высоким приоритетом однажды подменит системную библиотеку. В APT — /etc/apt/preferences.d/, в DNF — priority= в repo-файле.

Часть VIII. Различия семейств: где ломаются скрипты

Скрипт, отлично работающий на Ubuntu, падает на macOS и в Alpine-контейнере. Причина — три разных набора реализаций одних и тех же утилит: GNU coreutils (Linux), BSD utils (FreeBSD/OpenBSD/NetBSD/macOS), busybox (Alpine, embedded).

Задача GNU (Linux) BSD / macOS Busybox
Правка на месте sed -i 's/a/b/' f sed -i '' 's/a/b/' f как GNU
Канонический путь readlink -f p readlink -f нет; realpath (macOS 12.3+) или stat -f %R readlink -f есть
Дата минус час date -d '1 hour ago' date -v-1H ограничено
Base64 без переносов base64 -w0 base64 (не переносит) -w0 есть
Метаданные файла stat -c '%s %Y' f stat -f '%z %m' f как GNU
Пустой ввод в xargs нужен -r не запускает и так -r есть
Сортировка версий sort -V есть в FreeBSD 12+, нет в macOS есть
Рекурсия с -r grep -r grep -r есть есть
find -printf есть нет — только -exec нет
mktemp без шаблона работает нужен шаблон XXXXXX работает

Практические стратегии:

# 1. Проверять наличие, а не угадывать систему
if command -v gsed >/dev/null; then SED=gsed; else SED=sed; fi

# 2. На macOS ставить GNU-версии (Homebrew кладёт их с префиксом g)
$ brew install coreutils findutils gnu-sed gawk
$ export PATH="$(brew --prefix)/opt/coreutils/libexec/gnubin:$PATH"

# 3. Писать по POSIX там, где возможно, и проверять
$ shellcheck -s sh script.sh     # предупредит о башизмах
$ checkbashisms script.sh        # из devscripts, ещё строже

Оболочки тоже разные. На Debian /bin/sh — это dash (быстрый, минималистичный POSIX), а не bash. Скрипт с #!/bin/sh, использующий [[ ]], массивы, <<< или local с присваиванием, упадёт. На FreeBSD /bin/sh — потомок Almquist shell, в OpenBSD — pdksh (ksh). В macOS с версии Catalina логин-шелл по умолчанию — zsh (bash остался в версии 3.2 из-за лицензии GPLv3). Правило: #!/bin/sh = обещание писать по POSIX; хотите bash-фич — пишите #!/usr/bin/env bash.

Windows. PowerShell устроен принципиально иначе: между командлетами идут не байты, а типизированные объекты .NET. Это убирает целый класс проблем с парсингом:

# Ни одной регулярки: свойства объектов, а не колонки текста
Get-Process | Where-Object WorkingSet -gt 500MB |
    Sort-Object WorkingSet -Descending |
    Select-Object -First 5 Name, Id, @{n='MB';e={[int]($_.WorkingSet/1MB)}}

Плюс: структура не теряется, $_.WorkingSet — число, а не подстрока. Минус: работает только с программами, которые умеют в объектную модель; обычные exe отдают текст, и всё возвращается к разбору строк. Плюс многословность и заметно больший старт процесса.

WSL2 даёт полноценное ядро Linux в лёгкой VM с интеграцией файловых систем — на практике это лучший способ иметь Linux-инструментарий на Windows. Помните только про производительность: обращения из Linux к /mnt/c идут через 9P/virtio-fs и медленнее файлов внутри WSL раз в десять. Про архитектуру NT — в других ОС, про BSD-специфику — в семействе BSD.

Часть IX. Как писать скрипты, которые не подводят

Шаблон

#!/usr/bin/env bash
#
# Назначение: выгружает дамп БД и заливает в S3.
# Использование: backup.sh <база> <бакет>

set -Eeuo pipefail
# -E    : trap ERR наследуется функциями и подшеллами
# -e    : выход при ненулевом коде команды
# -u    : обращение к неопределённой переменной — ошибка
# -o pipefail : код конвейера = последний ненулевой

IFS=$'\n\t'                      # убрать пробел из разделителей
readonly SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)

usage() { printf 'Usage: %s <database> <bucket>\n' "${0##*/}" >&2; exit 64; }
log()   { printf '[%(%Y-%m-%dT%H:%M:%S%z)T] %s\n' -1 "$*" >&2; }
die()   { log "ОШИБКА: $*"; exit 1; }

(( $# == 2 )) || usage
readonly db=$1 bucket=$2

for cmd in pg_dump aws zstd; do
    command -v "$cmd" >/dev/null || die "не найдена утилита: $cmd"
done

workdir=$(mktemp -d)
trap 'rc=$?; rm -rf -- "$workdir"; exit $rc' EXIT
trap 'die "прервано на строке $LINENO"' ERR

log "дамп базы $db"
pg_dump --format=custom -- "$db" | zstd -19 -T0 > "$workdir/dump.zst"

log "загрузка в s3://$bucket/"
aws s3 cp -- "$workdir/dump.zst" "s3://$bucket/$db-$(date +%F).zst"
log "готово"

Ловушки set -e, о которых надо знать

set -e полезен, но его семантика полна исключений — это одна из самых критикуемых частей POSIX shell:

set -e

# 1. Не срабатывает в условии — и это правильно
if grep -q pattern file; then ...; fi     # grep вернул 1 → просто ветка else

# 2. НЕ срабатывает внутри функции, вызванной в условии — сюрприз
check() { false; echo "выполнится!"; }
if check; then :; fi                      # выведет «выполнится!»

# 3. Арифметика, вернувшая 0, — это код 1!
i=0
(( i++ ))                                 # значение выражения 0 → код возврата 1 → выход
(( i++ )) || true                         # правильно
i=$((i+1))                                # или так

# 4. Присваивание маскирует код команды
out=$(failing_cmd)                        # сработает, код=1
local out=$(failing_cmd)                  # НЕ сработает: код возврата у `local`, он 0
local out; out=$(failing_cmd)             # правильно — в две строки

# 5. Не работает в левой части && и в конвейере без pipefail
failing_cmd && echo ok                    # скрипт продолжится

Вывод: set -e — страховка, а не замена явным проверкам. Критическую логику проверяйте явно: cmd || die "...".

Инструменты качества

  • ShellCheck — статический анализатор, ловит подавляющее большинство описанных выше ошибок. Ставьте в CI и в редактор, это не обсуждается.
  • shfmt — форматтер (shfmt -i 4 -ci -w script.sh).
  • bats-core — фреймворк тестирования bash-скриптов.
  • bash -n script.sh — синтаксическая проверка без выполнения; bash -x — трассировка выполнения.
# Трассировка с номерами строк и именем функции — бесценно при отладке
$ PS4='+${BASH_SOURCE##\ast /}:${LINENO}:${FUNCNAME[0]:-main}(): ' bash -x ./deploy.sh
+deploy.sh:12:main(): db=production
+deploy.sh:15:check_deps(): command -v pg_dump

Когда бросить bash

Честный критерий: если скрипт перевалил за 100–150 строк, содержит вложенные структуры данных, требует обработки ошибок сложнее «упасть», или его надо тестировать — переписывайте на Python/Go. Bash великолепен для склейки команд и ужасен для алгоритмов: нет типов, нет вложенных структур, арифметика только целочисленная, обработка ошибок — набор исключений из правил. Хорошая эвристика от Google Shell Style Guide: bash допустим для скриптов до 100 строк, преимущественно вызывающих другие программы.

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

  1. «Кавычки — это стилистика». Нет. Отсутствие кавычек меняет число аргументов, передаваемых execve. Это семантика, а не оформление.
  2. «Конвейер выполняется по шагам». Нет, все звенья запускаются одновременно и работают конкурентно.
  3. «kill -9 — надёжный способ». Он гарантирует утечку временных файлов, потерю буферов и неснятые локи, а против состояния D не помогает вовсе.
  4. «ls можно распарсить». Никогда. Только glob, find -print0, stat.
  5. «$* и $@ — одно и то же». "$@" разворачивается в отдельные слова (правильно), "$*" — в одну строку через первый символ IFS.
  6. «Скрипт может сменить каталог вызывающему шеллу». Только через source, потому что иначе это отдельный процесс.
  7. «set -e ловит все ошибки». Смотри список исключений выше.
  8. «sudo и su -c — одно и то же». sudo сохраняет большую часть окружения по правилам env_reset/secure_path в sudoers; su - даёт полноценный login shell с чужим окружением. Разное $PATH — разные бинарники.
  9. «Права 777 — это “чтобы точно работало”». Это способ дать любому локальному процессу запись в ваши данные. Разбирайте настоящую причину через namei -l /path/to/file и id.
  10. «Всё в UTF-8, локаль не важна». Локаль меняет порядок сортировки, семантику диапазонов в регулярках, формат даты и чисел. Для машинных данных всегда LC_ALL=C.

Мини-итог

  • Shell — не «интерфейс к системе», а тонкая обёртка над fork/execve/pipe/dup2/waitpid. Каждое непонятное поведение объясняется этим набором.
  • Раскрытия идут в фиксированном порядке, и разбиение на слова с глоббингом происходят после подстановки значений. Отсюда абсолютное правило кавычек.
  • Конвейер — это параллельные процессы и 64-килобайтные буферы в ядре, а не последовательность шагов. Отсюда SIGPIPE, PIPESTATUS и проблемы с буферизацией libc.
  • Ctrl+C, фон, nohup, tmux — всё это следствия иерархии «сессия → группа процессов → управляющий терминал».
  • Лимиты процесса живут в rlimits и cgroups; в проде правильное место для них — юнит systemd, а не скрипт запуска.
  • GNU, BSD и busybox — три разных набора утилит. Проверяйте наличие флагов, а не угадывайте систему, и гоняйте ShellCheck.
  • Bash хорош до сотни строк. Дальше — нормальный язык.

Источники

Книги.

  • W. Richard Stevens, Stephen Rago. Advanced Programming in the UNIX Environment, 3rd ed. — главы 9 (сессии и группы процессов) и 10 (сигналы) объясняют job control лучше любой документации.
  • Michael Kerrisk. The Linux Programming Interfaceman7.org/tlpi. Главы 34, 44, 63. Автор — мейнтейнер man-pages, точность на уровне исходников.
  • Brian Kernighan, Rob Pike. The UNIX Programming Environment (1984) — старая, но лучшая книга про философию композиции.
  • Alfred Aho, Brian Kernighan, Peter Weinberger. The AWK Programming Language, 2nd ed. (2023) — обновлённая классика от самих авторов.

Документация.

  • GNU Bash Reference Manual — раздел «Shell Expansions» стоит прочитать целиком один раз.
  • POSIX.1-2024, Shell Command Language — что именно гарантировано переносимо.
  • BashFAQ и BashPitfalls — 60+ разобранных типичных ошибок, лучший ресурс в своём жанре.
  • Google Shell Style Guide.
  • man-страницы, которые окупают чтение: bash(1) (раздел EXPANSION), pipe(7), credentials(7), signal(7), setpgid(2), tty_ioctl(4), xargs(1), sort(1), proc(5).
  • Nix Manual и статья E. Dolstra, «The Purely Functional Software Deployment Model» (PhD thesis, 2006).
  • Debian Policy Manual и Fedora Packaging Guidelines — если когда-нибудь придётся паковать самому.

Инструменты.

Что дальше

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

Наблюдаемость и производительность ОС: /proc, strace, perf, eBPF, ftrace

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

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

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

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