Переменные окружения Linux: задать и сохранить
Почему переменная пропадает после sudo, su, в cron и systemd. Разбираем env, export, ~/.bashrc, ~/.profile и /etc/environment с проверками на Ubuntu 24.04.
Почему переменная окружения пропадает
Переменные окружения Linux хранятся не в системе целиком, а в каждом процессе отдельно. Процесс получает копию окружения от родителя в момент запуска и дальше живёт с этой копией. Поэтому export в одном терминале не виден в соседнем, а sudo, cron и systemd запускают команды с окружением, которое собирают сами.
Ниже четыре шага на свежем VPS с Ubuntu 24.04: как посмотреть окружение, как задать переменную, где её сохранить и куда она девается при переходе через sudo, su, cron и systemd. Готовых «ожидаемых» значений здесь почти нет. Вместо них даны команды. Документация описывает механизм, но точный результат зависит от образа и от того, что на сервере уже меняли. Запустите проверки у себя, и сервер сам покажет, как он настроен.
Все примеры написаны для bash. Имена взяты серверные: DATABASE_URL для строки подключения к базе и каталог /opt/tool/bin, который нужно добавить в PATH.
Как посмотреть переменные окружения: env, printenv и set
env и printenv без аргументов печатают всё окружение, по одной строке ИМЯ=значение. Если передать printenv имя, он напечатает только значение этой переменной:
printenv PATH
printenv DATABASE_URL
env | grep -E '^(PATH|HOME|LANG)='Если printenv DATABASE_URL ничего не вывел, переменной нет в окружении. Это ещё не значит, что её нет в shell. Встроенная команда bash set без аргументов печатает все переменные shell, в том числе неэкспортированные, а также функции. А printenv это отдельная программа. Она видит только то окружение, которое shell передал ей при запуске. Разницу легко увидеть:
DB_POOL_SIZE=20
set | grep '^DB_POOL_SIZE='
printenv DB_POOL_SIZEset найдёт строку, а printenv промолчит. Переменная есть в shell, но в окружение она не попала, потому что её не пометили через export. Именно такую переменную «теряет» любой скрипт, запущенный из этого терминала.
Окружение уже запущенного процесса: /proc/<pid>/environ
Иногда нужно узнать, с какими переменными работает сервис, который уже запущен. Для этого есть файл /proc/<pid>/environ. По странице proc_pid_environ(5) он содержит окружение, которое процесс получил при запуске через execve(2). Записи в нём разделены нулевым байтом, поэтому их переводят в строки через tr:
pgrep -o nginx
sudo cat /proc/$(pgrep -o nginx)/environ | tr '\0' '\n'sudo здесь нужен потому, что ядро проверяет доступ к этому файлу так же, как доступ отладчика (ptrace) к процессу. Окружение процесса другого пользователя без прав root обычно не прочитать. Конструкция $(...) подставляет вывод pgrep прямо в путь, подробнее о ней в разборе подстановки команд в bash.
Та же страница man говорит, что поздние изменения окружения в файл не попадают. Проверьте это на своём shell:
export DATABASE_URL='postgres://app@127.0.0.1:5432/app'
tr '\0' '\n' < /proc/$$/environ | grep '^DATABASE_URL='
bash -c 'tr "\0" "\n" < /proc/$$/environ | grep "^DATABASE_URL="'$$ означает PID текущего shell. Первая проверка смотрит на ваш shell, который стартовал раньше, чем вы выполнили export. Во второй строке $$ стоит в одинарных кавычках, поэтому его раскрывает новый bash, и это уже его собственный PID. Новый bash получил DATABASE_URL при старте. Если первая команда ничего не нашла, а вторая напечатала строку, вы увидели правило своими глазами: файл показывает окружение на момент запуска.
Вывод для сервера простой. Новая переменная не доходит до nginx или PostgreSQL, которые уже работают. Процесс нужно перезапустить, иначе он так и останется со старым окружением.
Как задать переменную окружения: VAR=x, export и unset
Присваивание без export создаёт переменную shell. Дочерние процессы её не получают:
unset DATABASE_URL
DATABASE_URL='postgres://app@127.0.0.1:5432/app'
bash -c 'echo "дочерний процесс: ${DATABASE_URL:-нет}"'
export DATABASE_URL
bash -c 'echo "дочерний процесс: ${DATABASE_URL:-нет}"'Первый bash -c напечатает нет. Второй напечатает строку подключения. В руководстве bash сказано, что export помечает имя для передачи в окружение команд, которые будут запущены после этого. Отсюда два следствия. export действует только на будущие процессы. И только на потомков этого shell: родительский процесс и соседний терминал ничего не узнают.
Если переменная нужна одной команде, поставьте присваивание перед ней:
DATABASE_URL='postgres://app@127.0.0.1:5432/app_test' ./migrate.shТакое присваивание меняет окружение только для этой команды. Текущий shell остаётся прежним, что удобно для тестов и разовых миграций. Учтите, что вся строка вместе с паролем остаётся в истории bash. Для настоящих секретов нужен файл с закрытыми правами, этот подход разобран в статье про env-файлы и секреты в Docker Compose.
Каталог в PATH добавляют так:
export PATH="/opt/tool/bin:$PATH"
command -v toolДвойные кавычки нужны, чтобы $PATH раскрылся сейчас. Каталог стоит в начале, поэтому shell ищет программы сначала в нём. Если в каталоге лежит исполняемый tool, command -v tool напечатает /opt/tool/bin/tool. Пустой вывод значит, что файла там нет или он не исполняемый.
Убрать переменную можно двумя способами. unset DATABASE_URL удаляет её совсем. export -n DATABASE_URL оставляет её в shell, но убирает из окружения, и дочерние процессы её больше не получат.
Частая ловушка: переменные записали в скрипт env.sh и запустили его как ./env.sh. Скрипт выполняется в отдельном bash. Он задаёт переменные себе, завершается, и переменные исчезают вместе с ним. Чтобы скрипт изменил текущий shell, его нужно прочитать через source ./env.sh. Разница подробно объяснена в статье source или запуск скрипта в bash.
Какой shell какой файл читает
Всё, что вы задали выше, живёт до закрытия терминала. Чтобы переменная пережила выход, её записывают в файл, который shell читает при старте. Какой именно файл, зависит от типа shell. Руководство bash различает такие случаи:
- Login shell, то есть вход по SSH,
su -,sudo -iилиbash -l. Он читает/etc/profile, затем первый найденный файл из списка~/.bash_profile,~/.bash_login,~/.profile. Только первый, остальные пропускаются. - Интерактивный shell без логина, например
bash, набранный в уже открытой сессии. Он читает~/.bashrc. - Неинтерактивный shell: скрипт,
bash -c, задание cron. Он не читает ни одного файла. Исключение одно: если задана переменнаяBASH_ENV, bash выполнит файл, который в ней указан. - Особый случай: bash, который sshd запустил для удалённой команды. Он неинтерактивный, но всё равно читает
~/.bashrc. Если bash вызван какsh, этого не происходит.
Из правила «только первый файл» следует частая поломка. Вы создали ~/.bash_profile ради одной строки, и ~/.profile перестал читаться. Вместе с ним пропало всё, что он делал, включая подключение ~/.bashrc. Если ~/.bash_profile нужен, подключите из него ~/.profile явно.
Что Ubuntu 24.04 кладёт в эти файлы
В исходниках пакетов Ubuntu 24.04 (ветка noble) файлы устроены так:
~/.bashrcиз/etc/skelначинается с проверкиcase $- in. Если shell неинтерактивный, файл сразу делаетreturn. Сам он ничего не экспортирует иPATHне трогает. Ещё он подключает~/.bash_aliases, если такой файл есть.~/.profileиз/etc/skelдля bash подключает~/.bashrc, если тот существует. Ещё он добавляет в началоPATHкаталоги$HOME/binи$HOME/.local/bin, если они есть./etc/profileдля интерактивного bash подключает/etc/bash.bashrc, а затем все читаемые файлы/etc/profile.d/*.sh. СамPATHон не задаёт./root/.profileу root приходит из пакета base-files, а не из/etc/skel. По исходникам он тоже подключает~/.bashrcдля bash, но файлы у root и у обычного пользователя могут отличаться.
Это исходники. На вашем образе файлы могли поменять, поэтому проверьте сами:
head -n 12 ~/.bashrc
grep -n 'bashrc' ~/.profile /etc/profile
sudo grep -n 'bashrc' /root/.profile
ls -la ~/.bash_profile ~/.bash_loginЕсли последняя команда отвечает No such file or directory для обоих файлов, при входе читается ваш ~/.profile. Если какой-то из них есть, ~/.profile при входе не читается.
Теперь проверим поведением, а не чтением файлов. Подключает ли ~/.profile файл ~/.bashrc на этом сервере? Поставим метку в первую строку ~/.bashrc, до проверки на интерактивность, и запустим login shell:
sed -i '1i export BASHRC_MARK=1' ~/.bashrc
bash -lc 'echo "метка: ${BASHRC_MARK:-нет}"'
sed -i '/^export BASHRC_MARK=1$/d' ~/.bashrcbash -l читает /etc/profile и ~/.profile, но сам ~/.bashrc не читает. Значит, метка: 1 означает, что файл ~/.profile подключил ~/.bashrc. метка: нет означает, что не подключил. Третья строка убирает метку.
Метку ставим в первую строку не случайно. Если дописать export в конец ~/.bashrc, он окажется после return. Тогда любой неинтерактивный shell остановится раньше, даже если ~/.profile подключил ~/.bashrc. Это классическая история: переменная видна в терминале, а скрипт, запущенный через ssh, её не видит.
Где сохранить переменную: ~/.profile, ~/.bashrc, /etc/profile.d или /etc/environment
Выбор зависит от того, кому нужна переменная.
- Только вам и в ваших сессиях:
~/.profile. Его читает login shell, а всё, что вы запускаете потом, наследует окружение. Сработает, если рядом нет~/.bash_profile. - Вам и в командах вида
ssh host 'команда':~/.bashrc, но строго выше блокаcase $- in. - Всем пользователям, с логикой shell вроде
$PATH: отдельный файл в/etc/profile.d/с расширением.sh. - Всем пользователям, простое готовое значение:
/etc/environment.
Каталог для всех пользователей добавляется так:
echo 'export PATH="/opt/tool/bin:$PATH"' | sudo tee /etc/profile.d/opt-tool.sh
sudo chmod 644 /etc/profile.d/opt-tool.sh
bash -lc 'command -v tool || echo "tool не найден"'Здесь sudo tee, а не sudo echo ... > файл. Перенаправление > выполняет ваш shell ещё до запуска sudo, с вашими правами, поэтому запись в /etc не удастся. Как tee пишет в файл и одновременно в вывод, разобрано в статье как отправить вывод в две команды через tee. Одинарные кавычки вокруг строки нужны, чтобы $PATH раскрывался при каждом входе, а не один раз сейчас. /etc/profile пропускает файлы, которые нельзя прочитать, поэтому права 644 важны.
/etc/environment устроен иначе. Это не скрипт, и shell его не читает. Его читает модуль PAM (pluggable authentication modules) pam_env в момент открытия сессии: при входе по SSH, в консоли, и в su -, если модуль включён в его настройки PAM. Формат по pam_env(8): простые пары KEY=VAL, по одной на строку. Слово export разрешено ради совместимости с bash, но игнорируется. Подстановку $VAR в этом файле man-страница не обещает, поэтому пишите туда только готовые значения. Изменение работает с нового входа. bash -c этот файл не читает никогда, он только наследует окружение от родителя.
Пароли и токены не кладите ни в /etc/environment, ни в /etc/profile.d. Сервисы эти файлы не читают, а пользователи сервера могут. Для сервиса нужен отдельный файл с правами 600, о правах подробнее в статье числовые и символьные права chmod.
Чтобы изменение в ~/.profile заработало в текущем терминале, выполните source ~/.profile или войдите заново.
Доходит ли /etc/environment до su -, runuser -l и bash -c
Man-страницы su и runuser для --login описывают одно и то же. Окружение очищается, остаются только TERM и переменные из --whitelist-environment. Затем заново задаются HOME, SHELL, USER, LOGNAME и PATH. После этого runuser всегда отдаёт окружение PAM на финальную обработку. Значит, строка из /etc/environment попадёт в сессию, только если в стеке PAM для этой команды есть pam_env. Проверим:
unset DATABASE_URL
echo 'DATABASE_URL=postgres://app@127.0.0.1:5432/app' | sudo tee -a /etc/environment
bash -c 'echo "bash -c: ${DATABASE_URL:-нет}"'
sudo su - "$USER" -c 'echo "su -: ${DATABASE_URL:-нет}"'
sudo runuser -l "$USER" -c 'echo "runuser -l: ${DATABASE_URL:-нет}"'
grep -n 'pam_env\|@include' /etc/pam.d/su /etc/pam.d/su-l /etc/pam.d/runuser /etc/pam.d/runuser-lЧитайте вывод так. bash -c наследует окружение текущей сессии, а она открылась до правки файла, поэтому там значения быть не должно. Если su - показал значение, PAM прочитал /etc/environment в этом стеке. Если runuser -l показал нет, ищите ответ в последней команде: строки pam_env для runuser-l нет ни в самом файле, ни в файлах, которые он подключает через @include. Если файл подключает другой, откройте и его. Результат на свежем VPS и в урезанном контейнере может отличаться, потому что файлы в /etc/pam.d у них разные.
Уберите тестовую строку:
sudo sed -i '/^DATABASE_URL=/d' /etc/environmentЧто sudo оставляет от вашего окружения
В пакете sudo для Ubuntu 24.04 файл /etc/sudoers содержит Defaults env_reset и такую строку:
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"По sudoers(5), env_reset запускает команду с новым минимальным окружением. Переменные, которых нет в списках env_keep и env_check, удаляются. Если задан secure_path, его значение становится PATH. Сам sudo secure_path по умолчанию не задаёт, это решение Ubuntu. Тот же /etc/sudoers подключает каталог /etc/sudoers.d, и в облачном образе там могут лежать свои правила. Поэтому смотрите на результат, а не на пакет:
export DATABASE_URL='postgres://app@127.0.0.1:5432/app'
export PATH="/opt/tool/bin:$PATH"
sudo env | grep -E '^(PATH|HOME|USER|DATABASE_URL)='
sudo sudo -V
sudo grep -rn -E 'secure_path|env_keep|env_reset' /etc/sudoers /etc/sudoers.d/Первая проверка покажет, что реально получила команда под sudo. Если DATABASE_URL в выводе нет, его удалил env_reset. Если PATH совпадает с secure_path, а /opt/tool/bin в нём нет, то и sudo tool не найдёт программу. sudo -V, запущенный от root, печатает глобальный список переменных, которые sudo сохраняет. Последняя команда показывает, в каком файле задана каждая настройка.
Как это обойти:
sudo /opt/tool/bin/tool --version
sudo env DATABASE_URL="$DATABASE_URL" /opt/tool/bin/tool migrateПолный путь решает проблему с PATH. Во второй строке $DATABASE_URL раскрывает ваш shell ещё до sudo. Затем sudo запускает env, а env задаёт переменную и запускает программу. Учтите, что аргументы sudo видны в ps всё время, пока работает команда. Для паролей это плохой путь.
Если программу нужно часто запускать через sudo, добавьте каталог в secure_path отдельным файлом:
sudo visudo -f /etc/sudoers.d/opt-toolDefaults secure_path="/opt/tool/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"visudo проверяет синтаксис перед сохранением. Ошибка в sudoers может отнять у вас sudo, поэтому не редактируйте эти файлы обычным редактором. Права нового файла visudo выбирает сам, поэтому не угадывайте их, а сравните с основным /etc/sudoers и при необходимости выровняйте:
sudo stat -c '%a %U:%G %n' /etc/sudoers /etc/sudoers.d/opt-tool
sudo chmod 0440 /etc/sudoers.d/opt-tool
sudo visudo -cПервая команда печатает права и владельца обоих файлов. Если у opt-tool права шире, чем у /etc/sudoers, chmod 0440 оставляет файлу только чтение для root и группы root, писать в него не может никто. visudo -c ещё раз проверяет синтаксис sudoers целиком. После этого снова выполните sudo env | grep ^PATH=.
Флаг -E (--preserve-env) просит политику sudo сохранить ваше окружение. Решает политика, поэтому проверьте результат через sudo -E env, а не полагайтесь на флаг.
sudo -i работает по-другому. Он запускает shell целевого пользователя как login shell, и окружение задаётся заново независимо от env_reset. HOME, MAIL, SHELL, USER и LOGNAME берутся от root. Login shell root читает /etc/profile, а через него /etc/profile.d/*.sh, и затем профиль root. Что окажется в PATH, покажет команда:
sudo -i env | grep -E '^(PATH|HOME|DATABASE_URL)='
sudo -i command -v toolЕсли вы создали /etc/profile.d/opt-tool.sh, вторая строка должна найти программу, потому что login shell прочитал этот файл. Ваш DATABASE_URL сюда не попадает: окружение собрано с нуля.
Всё это относится к классическому sudo в Ubuntu 24.04. Начиная с Ubuntu 25.10, включая 26.04 LTS, команду sudo предоставляет пакет sudo-rs, реализация на Rust. Как он обращается с окружением, проверяйте теми же командами на своей версии. Что ещё поменялось, описано в статье что меняет sudo-rs в Ubuntu.
Куда пропадает переменная: сводка
sudo команда:env_resetиsecure_pathиз/etc/sudoers. Ваши переменные, кроме списка изsudo -V, удаляются,PATHзаменяется.sudo -i: login shell root. Читаются/etc/profile,/etc/profile.d/*.shи профиль root. Вашиexportне доходят.su пользовательбез минуса: man-страница подробно описывает только режим--login. Что сохраняет простойsu, проверьте сами:su "$USER" -c 'printenv DATABASE_URL', затем то же сsu -.su -иrunuser -l: окружение очищается, остаютсяTERMи белый список.HOME,SHELL,USER,LOGNAME,PATHзадаются заново./etc/environmentдобавится, только если в стеке PAM естьpam_env.- cron: свои переменные, файлы shell не читаются.
- Сервис systemd: только то, что задано в unit-файле.
ssh host 'команда': переменные клиента не передаются,~/.bashrcчитается до первогоreturn.
Почему cron не видит переменные из ~/.bashrc
По crontab(5) в Ubuntu 24.04 cron задаёт SHELL=/bin/sh, а LOGNAME и HOME берёт из строки владельца crontab в /etc/passwd. HOME и SHELL можно переопределить в самом crontab, LOGNAME нельзя. Команда выполняется через /bin/sh как неинтерактивный shell без логина, поэтому ~/.bashrc, ~/.profile и /etc/profile.d не читаются. Нужные переменные задайте строками ИМЯ=значение в начале crontab или в самом скрипте. Чтобы увидеть окружение cron на своём сервере, добавьте временное задание * * * * * env > /tmp/cronenv и через минуту прочитайте файл. Остальные причины, по которым задание молчит, собраны в разборе почему не запускается задание cron.
Почему сервис systemd не видит переменные
Сервис systemd не читает ни одного файла shell. По systemd.exec(5) переменные приходят только из unit-файла и от менеджера: Environment= задаёт их прямо, EnvironmentFile= читает файл с присваиваниями по одному на строку, PassEnvironment= передаёт переменные самого менеджера. Если путь в EnvironmentFile= начинается с -, отсутствие файла не считается ошибкой. Если PATH не задан, системный менеджер использует фиксированное значение /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin, так что /opt/tool/bin нужно указать явно:
[Service]
Environment=PATH=/opt/tool/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
EnvironmentFile=-/etc/tool/tool.env
ExecStart=/opt/tool/bin/tool serveСекрет вроде DATABASE_URL кладите в /etc/tool/tool.env с правами 600. После правки unit выполните sudo systemctl daemon-reload и перезапустите сервис. Как написать unit и таймер с нуля, разобрано в статье сервисы и таймеры systemd на VPS.
ssh host 'echo $VAR': пример для проверки у себя
Для этого примера нужен второй сервер, поэтому запустите его сами:
ssh user@server 'echo "DATABASE_URL=${DATABASE_URL:-нет}"'Одинарные кавычки важны. Без них $DATABASE_URL раскроет ваш локальный shell, и вы увидите своё значение, а не серверное. Для такой команды sshd запускает bash, который читает ~/.bashrc. В Ubuntu ~/.bashrc выходит на return для неинтерактивного shell, поэтому export в конце файла не сработает. Перенесите его выше блока case $- in или используйте /etc/environment.
Переменная, заданная только на клиенте, на сервер не уйдёт. По sshd_config(5) sshd по умолчанию не принимает переменные клиента (AcceptEnv) и не читает ~/.ssh/environment (PermitUserEnvironment no). Файл конфигурации на вашем сервере может разрешать часть переменных, например LANG. Проверьте действующие значения:
sudo sshd -T | grep -i -E 'acceptenv|permituserenvironment'FAQ
Почему переменная из ~/.bashrc не видна в cron?
cron запускает команду через /bin/sh как неинтерактивный shell без логина. Такой shell не читает ~/.bashrc, ~/.profile и /etc/profile.d. Кроме того, ~/.bashrc в Ubuntu сразу выходит, если shell неинтерактивный. Задайте переменную строкой ИМЯ=значение в начале crontab или внутри скрипта, а окружение cron посмотрите временным заданием * * * * * env > /tmp/cronenv.
Почему после sudo пропадает /opt/tool/bin из PATH?
В Ubuntu 24.04 файл /etc/sudoers включает env_reset и задаёт secure_path. Под sudo PATH заменяется значением secure_path, поэтому ваш каталог исчезает. Проверьте это через sudo env | grep ^PATH=. Запускайте программу по полному пути или добавьте каталог в secure_path файлом в /etc/sudoers.d/, созданным через sudo visudo -f.
Чем /etc/environment отличается от /etc/profile.d?
Файлы /etc/profile.d/*.sh это скрипты shell. Их читает login shell через /etc/profile, и в них работают $PATH и любая логика bash. /etc/environment читает модуль PAM pam_env при открытии сессии. В нём только пары KEY=VAL, а слово export игнорируется. Ни тот, ни другой файл не читают cron, сервисы systemd и bash -c.
Как посмотреть переменные окружения работающего процесса?
Прочитайте /proc/<pid>/environ, например sudo cat /proc/$(pgrep -o nginx)/environ | tr '\0' '\n'. Файл показывает окружение на момент запуска процесса. Изменения, сделанные позже, в нём не видны. Поэтому после правки переменных сервис нужно перезапустить и проверить файл снова: PID будет новым.