hash: почему Bash запускает команду не из того каталога
Вы установили новую версию утилиты, проверили $PATH, но Bash продолжает запускать старую. При этом в другом терминале всё работает правильно.
Причина может быть во встроенном механизме hash. Bash запоминает пути к уже найденным исполняемым файлам, чтобы не искать их заново при каждом запуске.
▪️Как Bash ищет команды
Когда вы вводите:
mytool
Bash ищет подходящую команду. Если это внешняя программа, оболочка обычно проверяет каталоги из $PATH по порядку.
После первого успешного поиска путь может попасть во внутреннюю хеш-таблицу.
Посмотреть её содержимое:
hash
Или узнать сохранённый путь конкретной команды:
hash -t mytool
▪️Где возникает проблема
Допустим, утилита сначала находилась здесь:
/usr/bin/mytool
Затем вы установили новую версию в:
/usr/local/bin/mytool
Если Bash уже запомнил старый путь, он может продолжать использовать его, даже когда новая программа находится раньше в порядке поиска.
Это особенно заметно в долгоживущих сессиях.
▪️Как сбросить кеш
Удалить запись для одной команды:
hash -d mytool
Или очистить всю таблицу:
hash -r
После этого Bash заново выполнит поиск внешней команды.
Проверить все доступные варианты можно через:
type -a mytool
▪️Важный нюанс
При изменении $PATH Bash обычно сам очищает таблицу путей. Поэтому проблема чаще возникает, когда содержимое каталогов изменилось, а сама переменная осталась прежней.
Кроме того, hash не переопределяет shell-функции и встроенные команды Bash. Если имя занято функцией или alias, сначала нужно разобраться с порядком разрешения команд.
▪️Почему это важно
При обновлении утилит, переключении версий программ и диагностике окружения недостаточно проверить только $PATH.
Иногда Bash уже запомнил расположение исполняемого файла, и именно поэтому две одинаковые на первый взгляд сессии запускают разные версии программы.
Приложение работает несколько дней, а потом внезапно начинает выдавать Too many open files. Перезапуск помогает, но через какое-то время ошибка возвращается.
Возможно, процесс постепенно накапливает файловые дескрипторы. Причём это не обязательно обычные файлы: FD используются для сокетов, каналов, eventfd и других объектов.
▪️Сколько дескрипторов открыто
Для начала узнаем PID процесса и посчитаем его FD:
ls -U /proc/$PID/fd | wc -l
Посмотреть установленный лимит:
grep 'Max open files' /proc/$PID/limits
Если количество FD постоянно растёт и приближается к лимиту, стоит выяснить, что именно процесс держит открытым.
Это помогает понять, что накапливается: файлы, сетевые соединения, каналы или внутренние объекты ядра.
▪️Что показывает fdinfo
Для каждого открытого дескриптора Linux хранит дополнительную информацию:
cat /proc/$PID/fdinfo/15
Например:
pos: 4096
flags: 0100002
mnt_id: 27
ino: 123456
Здесь:
• pos - текущая позиция в файле.
• flags - флаги открытия и состояния FD в восьмеричном виде.
• mnt_id - идентификатор монтирования.
• ino - номер inode.
Для некоторых объектов появляются дополнительные поля.
Например, у epoll-дескриптора можно увидеть отслеживаемые FD, а у eventfd - значение счётчика.
▪️Как найти причину утечки
Если подозреваем обычные файлы, можно посмотреть, какие пути встречаются чаще всего:
Если сотни или тысячи дескрипторов указывают на один лог, файл или сокеты, это уже хорошая зацепка.
Для наблюдения за ростом:
watch -n 2 "ls -U /proc/$PID/fd | wc -l"
▪️Важный нюанс
Большое количество FD само по себе не означает утечку. Например, прокси может обслуживать десятки тысяч соединений одновременно.
Проблема начинается, когда дескрипторы продолжают накапливаться после завершения соответствующих операций.
И ещё: /proc/PID/fdinfo показывает состояние дескрипторов, но не объясняет, какая функция их открыла. Для поиска конкретного участка кода могут понадобиться strace, eBPF или профилирование приложения.
Команда из /tmp/bash-init выполнилась первой, хотя в основном скрипте её вообще нет.
▪️Зачем это нужно
Механизм позволяет автоматически загружать функции, переменные и настройки для неинтерактивных запусков Bash.
Например, можно определить общую функцию логирования, которая будет доступна нескольким скриптам.
Но есть и обратная сторона: поведение программы начинает зависеть от внешнего окружения.
▪️Где возникает проблема
Предположим, скрипт запускается из CI/CD или другого автоматизированного окружения.
Если туда попала переменная:
export BASH_ENV=/some/init.sh
каждый подходящий запуск Bash может сначала выполнить этот файл.
Причём внутри него могут находиться не только определения функций, но и обычные команды, изменяющие файлы или запускающие процессы.
▪️Как проверить
Посмотреть текущее значение:
printf '%s\n' "${BASH_ENV-}"
Запустить Bash без этой переменной:
env -u BASH_ENV bash script.sh
А если нужно исключить влияние остальных переменных окружения, можно использовать env -i, явно передав необходимые значения.
▪️Важный нюанс
BASH_ENV работает не во всех режимах запуска оболочки. Например, интерактивный Bash обычно использует ~/.bashrc, а при запуске в режиме sh действуют другие правила.
Кроме того, Bash игнорирует BASH_ENV в привилегированном режиме.
Поэтому при расследовании неожиданного поведения скрипта важно проверять не только его содержимое, но и способ запуска.
close_range(): как Linux закрывает тысячи файловых дескрипторов одним вызовом
Когда процесс запускает дочернюю программу, вместе с ней могут оказаться открытыми файловые дескрипторы родителя: сокеты, файлы, каналы и другие ресурсы.
Иногда это приводит к неожиданным проблемам. Например, дочерний процесс продолжает держать сокет открытым, хотя основная программа давно его закрыла.
▪️Почему обычного close() недостаточно
Допустим, приложение открыло несколько тысяч дескрипторов. Перед запуском другой программы нужно закрыть все лишние.
Но если максимальный номер дескриптора большой, программа выполнит множество системных вызовов, в том числе для уже закрытых FD.
▪️Как работает close_range()
Начиная с Linux 5.9 доступен системный вызов:
close_range(3, ~0U, 0);
Он закрывает все открытые дескрипторы начиная с номера 3.
При этом стандартные потоки остаются доступными:
0 → stdin
1 → stdout
2 → stderr
Ядро обрабатывает диапазон за один системный вызов, без необходимости вызывать close() для каждого номера отдельно.
▪️А что происходит при fork() и exec()?
Здесь есть важное различие.
После fork() дочерний процесс наследует копии дескрипторов родителя. Они ссылаются на те же открытые файловые объекты.
При execve() дескрипторы по умолчанию сохраняются, если для них не установлен флаг FD_CLOEXEC.
Поэтому случайно унаследованный FD может пережить даже запуск совершенно другой программы.
▪️Можно не закрывать, а установить CLOEXEC
У close_range() есть дополнительный режим:
close_range(3, ~0U, CLOSE_RANGE_CLOEXEC);
Он устанавливает FD_CLOEXEC для дескрипторов в диапазоне.
В результате они останутся открытыми до execve(), но будут автоматически закрыты при успешной замене образа процесса.
Это удобно, когда нужно подготовить окружение дочерней программы.
▪️Почему это важно
Лишние унаследованные дескрипторы могут удерживать файлы, мешать закрытию соединений и давать дочерним программам доступ к ресурсам, которые им не нужны.
close_range() позволяет быстро закрыть целый диапазон FD или подготовить их к закрытию при execve().
Но есть нюанс: в многопоточном процессе таблица дескрипторов может быть общей для нескольких потоков. Если нужна отдельная таблица перед изменением FD, предусмотрен флаг CLOSE_RANGE_UNSHARE.
O_APPEND: почему несколько процессов могут писать в один файл
Допустим, два процесса одновременно записывают события в один лог. Оба открывают файл, находят его конец и добавляют новую строку.
Но что произойдёт, если они сделают это одновременно? Один процесс может перезаписать данные другого.
В Linux для таких случаев есть флаг O_APPEND.
▪️Что происходит без O_APPEND
Предположим, оба процесса выполняют:
lseek(fd, 0, SEEK_END);
write(fd, data, size);
Между этими вызовами другой процесс может изменить файл.
Получается ситуация:
Process A → lseek() → offset 100
Process B → lseek() → offset 100
Process A → write()
Process B → write()
Оба процесса начинают запись с одной позиции. Часть данных может быть перезаписана.
▪️Как работает O_APPEND
Открываем файл с дополнительным флагом:
int fd = open("app.log",
O_WRONLY | O_APPEND);
Теперь перед каждым write() ядро устанавливает позицию записи в конец файла.
Причём выбор позиции и сама запись выполняются как одна атомарная операция относительно конкурирующих записей.
Поэтому два процесса не смогут одновременно выбрать один и тот же конец файла и перезаписать друг друга из-за этой гонки.
▪️Можно проверить через Bash
Оператор >> открывает файл в режиме добавления:
echo "First event" >> app.log
Запустим несколько фоновых процессов:
for i in {1..100}; do
printf 'Event %s\n' "$i" >> app.log &
done
wait
Каждый printf открывает файл с O_APPEND, поэтому записи не должны перезаписывать друг друга из-за совпавшей позиции.
▪️Но есть ограничение
O_APPEND не превращает последовательность нескольких write() в одну атомарную транзакцию.
Если приложение записывает сообщение частями, между ними могут появиться данные другого процесса.
Кроме того, на некоторых сетевых файловых системах, например NFS, добавление в конец может реализовываться неатомарно.
▪️Почему это важно
Для логов, журналов событий и других файлов, в которые одновременно пишут несколько процессов, O_APPEND устраняет распространённую гонку между поиском конца файла и записью.
Но если требуется гарантировать целостность целого сообщения, состоящего из нескольких операций, одного O_APPEND недостаточно.