Механика shell: подоболочки, sourcing, exec

TL;DR: Три способа выполнить код — три разных механизма работы с процессами и окружением. ./script.sh — новый процесс (подоболочка), изменения окружения не видны родителю. . script.sh (sourcing) — выполнение в текущей оболочке, изменения остаются. exec cmd — замена текущего процесса, возврата нет.

Зачем это знать

Непонимание разницы между этими механизмами — источник классических багов:

  • «Поставил cd /app в скрипте, но после запуска всё ещё в старой директории» — подоболочка
  • «Экспортировал переменные в env.sh, но в терминале их нет» — запустили как ./env.sh, а нужен . env.sh
  • «Docker-контейнер не получает сигналы, не завершается по SIGTERM» — не использовали exec в entrypoint

Подоболочки (subshells)

Подоболочка — новый процесс shell, который получает копию окружения родителя. Любые изменения (переменные, cd, ulimit) исчезают при выходе из подоболочки.

Когда создаётся подоболочка

# 1. Запуск скрипта как команды
./script.sh              # новый процесс bash
bash script.sh           # тоже новый процесс
 
# 2. Круглые скобки — явная подоболочка
(cd /tmp; tar xf archive.tar)
# После выполнения — вы всё ещё в исходной директории
 
# 3. Пайп (каждая часть — подпроцесс)
echo "hello" | while read line; do
    VAR="$line"
done
echo "$VAR"   # пусто! while работал в подоболочке пайпа

Практическое применение

Подоболочки полезны, когда нужно временное изменение, не затрагивающее основной скрипт:

# Выполнить команду в другой директории, не трогая pwd
(cd /var/log && tar czf /tmp/logs.tar.gz *.log)
 
# Временно изменить PATH
(PATH=/opt/custom/bin:$PATH; custom_tool --run)
 
# Копирование дерева каталогов с сохранением прав
# (быстрее и надёжнее cp -r)
tar cf - orig | (cd target; tar xvf -)

Последний пример — приём из книги Уорда: tar архивирует на лету, пайп передаёт в подоболочку, которая сделала cd в целевую директорию и распаковывает. Проверяйте, что target существует и отделён от orig (в скрипте: [ -d target -a ! orig -ef target ]).

Встроенный синтаксис для одноразовых переменных: подоболочка для одной переменной — перебор. Shell позволяет задать переменную прямо перед командой:

PATH=/opt/custom:$PATH custom_tool    # без подоболочки
LANG=C sort file.txt                  # сортировка в C-локали

Sourcing (включение файла)

Оператор . (точка) или source выполняет файл в текущей оболочке. Переменные, функции, изменения cd — всё остаётся после выполнения.

# Два эквивалентных синтаксиса
. ./config.sh          # POSIX (работает в любом sh)
source ./config.sh     # Bash-специфичный

Sourcing vs запуск

./script.sh              . ./script.sh
     │                        │
  fork()                 (нет fork)
     │                        │
  Новый bash              Текущий bash
  читает script.sh       читает script.sh
     │                        │
  exit                    Продолжает работу
     │                        │
  Изменения потеряны      Изменения СОХРАНЕНЫ

Это не то же самое, что выполнение скрипта как команды: при запуске ./script.sh он работает в новой оболочке и не отдаёт ничего, кроме вывода и кода возврата.

Когда использовать sourcing

# 1. Загрузка конфигурации
# config.sh:
#   DB_HOST="localhost"
#   DB_PORT=5432
. ./config.sh
echo "Connecting to $DB_HOST:$DB_PORT"
 
# 2. Загрузка общих функций (библиотека)
# lib/utils.sh:
#   log() { echo "[$(date '+%H:%M:%S')] $*"; }
#   error() { echo "[ERROR] $*" >&2; exit 1; }
. ./lib/utils.sh
log "Script started"
 
# 3. Активация окружения
. ~/.nvm/nvm.sh           # Node Version Manager
. ./venv/bin/activate     # Python virtualenv

Осторожно: sourcing выполняет все команды файла в вашей оболочке. exit внутри sourced-файла завершит вашу оболочку. Поэтому в sourced-файлах определяют только переменные и функции, избегая exit и побочных эффектов.

exec — замена процесса

exec — встроенная команда shell, которая заменяет текущий процесс оболочки на указанную программу (через системный вызов exec()). Возврата нет — shell перестаёт существовать. Смысл — экономия ресурсов: один процесс вместо двух.

# В терминале:
exec cat        # shell заменён на cat
# Ctrl+D → терминал закроется (shell больше нет)
 
# В скрипте:
#!/bin/bash
echo "Preparing..."
exec /usr/bin/myapp --flag
echo "This line NEVER executes"

Docker entrypoint — главный use case

#!/bin/bash
# entrypoint.sh
export DB_URL="postgres://${DB_HOST}:${DB_PORT}/app"
 
# Заменить shell на приложение — оно становится PID 1
# и получает сигналы Docker (SIGTERM при docker stop)
exec "$@"

Без exec shell остаётся PID 1, перехватывает SIGTERM, а приложение его не получает — контейнер зависает на docker stop (10 сек ожидания → SIGKILL).

exec для перенаправления

Менее известное применение — перенаправление файловых дескрипторов для всего скрипта:

# Весь stdout скрипта → в файл
exec > /var/log/myscript.log 2>&1
echo "Это пойдёт в файл, не в терминал"

Подстановка команд: $() — подводные камни

$() запускает команду в подоболочке и подставляет её stdout. Внутри можно использовать пайпы, кавычки и вложенные подстановки:

FLAGS=$(grep ^flags /proc/cpuinfo | sed 's/.*://' | head -1)
DATE=$(date +%Y-%m-%d)

Чего НЕ делать

# ❌ $(ls) — бессмысленно и ломается на пробелах
for f in $(ls *.txt); do ...     # если файл "my file.txt" → две итерации
for f in *.txt; do ...           # ✅ shell glob — безопасно и быстро
 
# ❌ $(find ...) — ломается на пробелах
files=$(find . -name '*.log')
rm $files                        # ОПАСНО
 
find . -name '*.log' -print0 | xargs -0 rm    # ✅ безопасно
find . -name '*.log' -exec rm {} +             # ✅ без xargs

Подробнее о xargs и find: text-processing.

Старый синтаксис — обратные апострофы: FILES=`ls`. Работает, но $() читаемее, поддерживает вложенность ($(cmd1 $(cmd2))) и является стандартом POSIX.

Сводка: три механизма

ПодоболочкаSourcingexec
Синтаксис./script.sh, (cmd). file / source fileexec cmd
Новый процесс?Да (fork)НетНет (замена текущего)
ОкружениеКопия → теряетсяТекущее → сохраняетсяЗаменяется
Возврат к shellДаДаНет
Типичное применениеИзоляция, скриптыКонфиги, библиотекиDocker entrypoint, экономия PID

Когда НЕ использовать shell-скрипты

Shell идеален для склейки команд, манипуляции файлами и автоматизации системных задач. Но если вы ловите себя на:

  • Сложных строковых операциях — парсинг JSON/XML/YAML, regex с группами захвата
  • Арифметике сложнее $((a + b))expr неуклюж и медленен
  • Работе со структурами данных — ассоциативные массивы в bash существуют, но неудобны
  • Сложном вводе от пользователяread для подтверждения «Вы уверены?» годится, но формы с валидацией — нет
  • Сетевых запросах с обработкой ответов — curl допустим, но парсинг JSON из ответа — Python
  • Скрипте длиннее 100–150 строк — сложность начинает превышать возможности языка

→ переходите на Python, Perl или другой полноценный язык. Стыда в этом нет — это зрелое инженерное решение.

Связанные материалы

  • write-scripts — шаблон скрипта, аргументы, массивы, mktemp, отладка
  • text-processing — sed, awk, xargs — утилиты для конвейеров
  • 04-shell-and-scripting — первые шаги: переменные, циклы, функции
  • process-model — fork, PID, сигналы — механика под капотом