Командная строка и инструментарий Linux: shell, утилиты, процессы, пакеты
Есть распространённое заблуждение, что командная строка — это «устаревший интерфейс для тех, кому лень поставить GUI». Это ровно наоборот. Графический интерфейс — это фиксированный набор заранее предусмотренных действий. Командная строка — это язык программирования, в котором любая установленная в системе программа является функцией, а вывод одной функции можно подать на вход другой. GUI даёт вам меню; shell даёт вам композицию.
Практическое следствие: то, что в GUI требует специальной кнопки (а её обычно нет), в shell собирается за тридцать секунд из четырёх утилит, каждая из которых написана в 1979 году и с тех пор не менялась. Найти все процессы, открывшие удалённый файл, посчитать распределение кодов ответа по логу за последний час, отсортировать контейнеры по потреблению памяти — ничего из этого никто специально не программировал. Оно получается композицией.
Но у этой мощи есть цена. Shell — язык с чрезвычайно необычной семантикой: он подставляет, а не вычисляет; результат подстановки снова разбирается как код; ошибки по умолчанию игнорируются. Программист, пришедший из Python или Go и пишущий bash «по аналогии», гарантированно напишет скрипт, который однажды удалит не тот каталог. Поэтому эта статья — не сборник рецептов, а разбор механики: что shell делает с вашей строкой, какие системные вызовы за этим стоят и где именно проходят острые края.
Статья продолжает Linux для программиста и опирается на устройство процессов из процессов и планировщиков, на файловые дескрипторы из системных вызовов и IPC и на cgroups из безопасности и изоляции. Если что-то из перечисленного проскочило мимо — читайте эту статью как практическое подтверждение того материала.
Часть I. Что такое shell на самом деле
Три системных вызова, из которых собрано всё
Когда вы вводите ls -l /etc, происходит не «выполнение команды», а вполне конкретная последовательность:
- Shell разбирает строку в список слов:
["ls", "-l", "/etc"]. - Проверяет, не встроенная ли это команда (
cd,export,read— их около полусотни в bash).lsне встроенная. - Ищет
lsв каталогах из$PATHслева направо, кэшируя результат в хеш-таблице (hash -rеё сбрасывает). - Вызывает
fork(2)— создаёт свою копию. - В копии вызывает
execve("/usr/bin/ls", argv, envp)— заменяет образ процесса на новый, сохраняя pid и открытые дескрипторы. - В родителе вызывает
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.
поиск в $PATH (кэш hash) B->>K: fork() / clone(SIGCHLD) K-->>B: pid = 4712 K-->>C: pid = 0 (копия адресного пространства, COW) Note over C: настройка дескрипторов:
dup2, close, установка pgid C->>K: execve("/usr/bin/ls", argv, envp) Note over K: разбор ELF, mmap сегментов,
загрузка ld.so, точка входа B->>K: waitpid(-1) — блокируется C->>K: write(1, "...") на терминал C->>K: exit_group(0) K-->>B: SIGCHLD + статус Note over B: $? = 0, печать prompt B->>U: приглашение
Встроенные, функции, алиасы, внешние: порядок разрешения
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 не «передаёт строку программе». Он выполняет над строкой семь стадий преобразований в фиксированном порядке, и результат каждой стадии участвует в следующих. Ошибка почти всегда в том, что человек не ожидал очередной стадии.
echo ~/logs/*.txt $N "$(date +%F)""] --> B["1. Раскрытие скобок
{a,b} → a b
ДО всего остального, чисто текстовое"] B --> C["2. Тильда
~ → /home/user, ~root → /root"] C --> D["3. Параметры и переменные
$N, ${N:-def}, ${N#pre}, ${N//a/b}"] D --> E["4. Подстановка команд
$(...) и обратные кавычки"] E --> F["5. Арифметика
$((1+2)) — целые, 64 бита"] F --> G{"Результат стадий 3-5
был в двойных кавычках?"} G -->|Нет| H["6a. РАЗБИЕНИЕ НА СЛОВА
по символам из IFS
= пробел, таб, перевод строки"] G -->|Да| I["Разбиение пропущено —
значение остаётся ОДНИМ словом"] H --> J["6b. Раскрытие путей (glob)
* ? [] — сверка с ФС.
Нет совпадений → шаблон остаётся как есть"] I --> K["7. Удаление кавычек"] J --> K K --> L["Итоговый argv[] → execve"] style G fill:#b58a5a22,stroke:#b58a5a style H fill:#b56a6a22,stroke:#b56a6a style J fill:#b56a6a22,stroke:#b56a6a
Красные стадии — те, что происходят после подстановки значения и применяются к результату. Отсюда правило, которое стоит выжечь на подкорке:
Каждое раскрытие переменной или команды должно быть в двойных кавычках, кроме случаев, когда вы сознательно хотите разбиения и глоббинга.
Смотрим, что бывает без них:
$ 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
Конвейер как параллельная программа
Ключевой факт, который меняет способ мышления: конвейер — это не последовательность шагов, а множество одновременно работающих процессов, связанных буферами в памяти ядра.
Отсюда вытекает практика:
Конвейер даёт параллелизм бесплатно. 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 — конечный автомат:
или нормальный выход Foreground --> Background: Ctrl+Z, затем bg Background --> Foreground: fg %1 Background --> Stopped: SIGTTIN — попытка
читать с терминала Background --> Stopped: SIGTTOU — вывод
при stty tostop Background --> [*]: завершение
(bash сообщит [1]+ Done) Stopped --> Foreground: fg %1 (SIGCONT + tcsetpgrp) Stopped --> Background: bg %1 (SIGCONT) Stopped --> [*]: kill %1 note right of Stopped Состояние T в ps. SIGSTOP и SIGKILL нельзя перехватить или проигнорировать. end note note left of Background disown %1 — убрать из таблицы job'ов шелла: SIGHUP при выходе не придёт. end note
Как правильно запустить долгий процесс
Четыре разных ответа для четырёх разных задач:
# 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-решатели), атомарность обновления, обработка изменённых конфигов, целостность через подписи, откат.
управления
пакетами)) Системные бинарные dpkg + APT Debian Ubuntu Mint формат deb — ar с tar внутри конфиги через ucf и dpkg-conffiles RPM + DNF Fedora RHEL SUSE libsolv — SAT-решатель транзакции с откатом pacman Arch rolling release минимум абстракций apk Alpine крошечный и быстрый основа контейнеров Из исходников Portage Gentoo USE-флаги — вариативность сборки FreeBSD Ports make install clean poudriere для сборки бинарных pkgsrc NetBSD и не только переносим на 20 платформ Homebrew macOS и Linux формулы на Ruby Функциональные Nix хеш пути от всех входов несколько версий рядом воспроизводимость и откат Guix та же модель на Scheme Изолированные приложения Flatpak рантаймы и порталы песочница bubblewrap Snap squashfs образы нужен snapd AppImage один файл без установки Языковые pip npm cargo gem go конфликтуют с системными решение — venv и локальные каталоги Контейнерные OCI-образы зависимости внутри слоёв
Практика: 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 строк, преимущественно вызывающих другие программы.
Типичные заблуждения
- «Кавычки — это стилистика». Нет. Отсутствие кавычек меняет число аргументов, передаваемых
execve. Это семантика, а не оформление. - «Конвейер выполняется по шагам». Нет, все звенья запускаются одновременно и работают конкурентно.
- «
kill -9— надёжный способ». Он гарантирует утечку временных файлов, потерю буферов и неснятые локи, а против состоянияDне помогает вовсе. - «
lsможно распарсить». Никогда. Только glob,find -print0,stat. - «
$*и$@— одно и то же»."$@"разворачивается в отдельные слова (правильно),"$*"— в одну строку через первый символIFS. - «Скрипт может сменить каталог вызывающему шеллу». Только через
source, потому что иначе это отдельный процесс. - «
set -eловит все ошибки». Смотри список исключений выше. - «sudo и su -c — одно и то же».
sudoсохраняет большую часть окружения по правиламenv_reset/secure_pathв sudoers;su -даёт полноценный login shell с чужим окружением. Разное$PATH— разные бинарники. - «Права 777 — это “чтобы точно работало”». Это способ дать любому локальному процессу запись в ваши данные. Разбирайте настоящую причину через
namei -l /path/to/fileиid. - «Всё в 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 Interface — man7.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 — если когда-нибудь придётся паковать самому.
Инструменты.
- ShellCheck, shfmt, bats-core.
- ripgrep, fd, jq.
- explainshell.com — разбирает произвольную команду по man-страницам; отличный способ понять чужой однострочник.
Что дальше
Мы научились управлять системой из командной строки. Следующий шаг — научиться видеть, что в ней происходит: почему процесс тормозит, куда уходит время, кто ест память, какие системные вызовы делает приложение и как это измерить без остановки продакшена.
Наблюдаемость и производительность ОС: /proc, strace, perf, eBPF, ftrace