Как появились и менялись переменные окружения
Детективное расследование о Unix.
Иллюстрация: Bell-Labs / Wikimedia commons / Colowgee для Skillbox Media
Недавно мне нужно было сделать материал для онлайн-урока на тему переменных окружения в Linux. Да, вот про те самые PATH, PS1 и TERM, знакомые каждому, кто хотя бы иногда работает в консоли.
Иногда студенты спрашивают у меня: «Это вообще кому-то нужно сегодня?» Ещё как нужно! Окружения — это базовый механизм настройки приложений в любой Unix-подобной системе, будь это ноутбук с macOS, сервер в облаке или контейнер Kubernetes.
В прекрасном манифесте The twelve-factor app про построение масштабируемых веб-приложений, включая микросервисы, в разделе про конфиги чётко и понятно подаётся мысль: отделяйте конфиги от кода и храните настройки в переменных окружения.
Роман Гордеев
Техлид в финтех-компании Tabby и автор курсов про тестирование на Python в Skillbox.
В далёком 2000 году впервые увидел чёрный экран FreeBSD 4.0 и полюбил Unix-консоль с первого взгляда.
Что такое переменные окружения?
Переменные окружения — это набор значений, которые определяют настройки и поведение операционной системы и программ, работающих в ней. Они представляют собой пары «ключ — значение» и хранятся в памяти, упрощая работу с приложениями. В большинстве статей на этих определениях и заканчивают, не раскрывая деталей реализации.
Но важно помнить, что наше понимание работы любой технологии — это модель в нашем сознании, которую можно и нужно уточнять. Сделать это проще всего ретроспективно — посмотреть на ранний этап развития технологии, когда её идея только зародилась и не успела обрасти улучшениями. Давайте посмотрим на то, когда возникли переменные окружения и как они эволюционировали до нашего времени.
Переменные окружения в их современном виде появились в 1979 году в седьмой версии Unix (Unix V7). Если посмотреть на генеалогию Unix-like операционных систем, то мы видим, что эта ОС находится у самых корней дерева, то есть в самом начале эпохи развития Linux, macOS и FreeBSD. Одним из компьютеров, работавших на ней, был PDP-11.
Это же больше сорока лет назад! Какую проблему решали авторы, придумывая переменные окружения и само окружение. Актуальны ли они сейчас? Как это работало? Если вы дочитали до этого места и вам тоже интересны ответы на эти вопросы, то давайте начнём вместе искать на них ответы!
В чём главная идея переменных окружения
Для начала разберёмся с идеей конфигурации и попробуем ответить на вопрос — почему она появилась? В этом нам поможет Джон, вымышленный сотрудник лаборатории компании DEC. Хай, Джон!
DEC — это компания, которая разрабатывала компьютеры PDP-11 и другие устройства. В распоряжении нашего Джона был терминал Teletype Model 33, вот как на фото выше. Работать было просто и удобно — Джон запускал исполняемый файл утилиты cat, набирая её имя на терминале. PDP-11 печатал результат запуска приложения со скоростью 100 слов в минуту, строка за строкой, как печатная машинка. Шумновато, но быстро.
Всё отлично работало, но прогресс не стоял на месте. Спустя небольшое время DEC разработала видеотерминал VT52 и один из них попал к Джону.
Новый терминал отличается от старого — вместо листа бумаги у него дисплей! Из-за этого поменялось число выводимых символов: в Teletype Model 33 помещалось 72 символа в строке, а в VT52 уже 80 символов. Поменять эти значения невозможно: Teletype Model 33 — это печатная машинка, да и VT52 умеет отображать только символы ASCII и только одного размера.
Но версия cat, которую использовал Джон, в работе рассчитывает на то, что в строке только 72 символа. Надо собирать новые версии приложений с поддержкой VT52. Выходит, будет две версии cat под разные терминалы, различающиеся только одной небольшой настройкой? Это нерационально, тем более количество вариантов устройств может увеличиваться и дальше. Но cat не понимает, за каким терминалом сейчас работает Джон.
Самый простой способ объяснить ему это — при запуске приложения указывать параметр, соответствующий терминалу. Например, так: cat TERM=dumb для Teletype Model 33 и cat TERM=vt52 для нового терминала с монитором. Джон договаривается с Деннисом Ритчи, и скоро появляется cat с поддержкой любой длины строк. Теперь можно использовать его и на новом, и на старом устройстве, указывая нужный параметр.
Параметризация приложения дала нам возможность использовать один и тот же файл приложения в разных случаях. С этой идеей разобрались, но зачем понадобилось разделить конфигурацию на параметры приложения и на окружение?
К тому моменту в Unix было написано уже несколько десятков приложений. В каждое из них пришлось добавлять поддержку параметра TERM, который подсказывал, с каким окружением приходится иметь дело. Но указывать TERM в параметрах — лишняя работа. Идеальный вариант — настроить всё окружение один раз и предоставить Unix самостоятельно подставлять параметры этих настроек для всех запускаемых приложений. Этот механизм — разделение параметров на окружение и собственно параметры приложения — и был реализован в Unix V7.
Помещая часть настроек в окружение, мы можем не настраивать каждое приложение по отдельности.
Идея понятна, спасибо Джону!
Читаем самый первый мануал про окружение
Как же выглядела настройка окружения в одной из самых первых версий Unix? Посмотрим в руководство UNIX Time Sharing System — UNIX Programmer’s Manual — Seventh Edition, Volume 1A от января 1979 года. На странице 161 в разделе про командный интерпретатор Shell есть информация и про окружение (environment).
Резюмирую для вас то, что там написано:
- Окружение — это список пар «ключ — значение», который передаётся выполняемой программе так же, как и обычный список аргументов.
- Для добавления переменных окружения используйте новую команду export.
- Запускаемые из Shell команды наследуют одно и то же окружение.
- Shell создаёт из переменных окружения параметры.
Всё работает точно так же, как и в современной Unix-подобной операционной системе. Более того, в мануале упоминаются переменные окружения PATH, HOME, TERM и другие, привычные нам по современной разработке.
С точки зрения пользователя способ работы с переменными окружения был придуман ещё на заре Unix и остался неизменным до сих пор.
В этом месте мне стало понятно, что в материалы онлайн-урока это упражнение, конечно, не войдёт, но своё «расследование» остановить уже не мог. Я решил разобраться — где в Unix хранилось это самое окружение?
Подкапотное пространство
Чтобы понять, что в Unix было сделано для работы окружения, опять загляну в документацию, ссылки из раздела про Shell ведут нас в ENVIRON (5) (пятёрка и другие числа в скобках далее — это номер раздела в документации).
Документация лаконична. Environ — это глобальная переменная, которая создаётся в результате вызова EXEC (2). Раз это глобальная переменная, то окружение хранится в памяти процесса. А процесс в операционной системе — это запущенный исполняемый файл в ней.
Но что такое вызов EXEC и как он создавал в памяти приложения Environ?
Вызов EXEC происходит во время работы пользователя в Shell. Когда он хочет запустить приложение, то набирает путь к его исполняемому файлу и предоставляет остальную работу Shell. Последний тоже не запускает приложение сам — он вызывает EXEC, передавая в нём параметры запуска приложения и параметры управления запуском операционной системы Unix.
Как же Unix обрабатывала EXEC? Посмотрим на документацию. Запуская программу на C, операционная система вызывает функцию main с аргументами main (argc, argv, envp). В argc, argv задаётся количество аргументов, которые были переданы, и список указателей на них. А вот envp — это указатель на окружение родительского процесса. В нашем примере это Shell, со всеми его переменными.
Но ещё до того, как выполнится main, будет создана копия того окружения, что находится в envp, и приложению она будет доступна как глобальная переменная environ. За заполнение списка переменных отвечает Shell, а за создание копии этого списка в памяти создаваемого процесса — сама операционная система.
Получается, что новый процесс получает копию окружения своего родительского процесса. Благодаря такому копированию, каждое приложение может менять свою копию настроек, не заботясь о том, что это сломает другие приложения. А если новый процесс тоже запустит приложение, то создастся дочерний процесс, который получит копию окружения уже родителя, но не его предков.
Работа с переменными окружения была поддержана в вызовах Unix, а каждое приложение имело собственную копию окружения родительского процесса в своей памяти.
Первое появление понятия окружения в седьмой версии Unix подтверждает то, что в мануалах шестой версии упоминаний про это нет. В документации про вызов EXEC (2) есть только два параметра — argc и argv, а envp отсутствует, то есть окружений ещё не было.
Ещё в дополнении к мануалу V7 — Annotated Excerpts from the Programmer’s Manual — можно найти замечание о том, что привычная нам Shell (Bourne shell) была написана как раз для седьмой версии, заменив предыдущую. Без поддержки в Shell окружение не было бы таким удобным для работы.
Что стало с окружением потом
Итак, у нас есть ответы на все вопросы, но я хочу посмотреть, что стало с окружением в современном Linux. Может быть, управление им изменилось?
Проверку делаю на Linux, запущенном в Docker, а для решения задачи использую джентльменский набор разработчика: утилиту Strace для просмотра системных вызовов и утилиту GDB для просмотра содержимого памяти у процессов.
Перед тем как смотреть на вызовы, сначала представлю себе, что я хочу увидеть. Отправной точкой будет вызов exec(). Почему он? Начиная с 1988 года существуют стандарты POSIX, определяющие то, как должны выглядеть системные вызовы.
Unix V7, про который мы говорили, появился раньше, но стандарты POSIX разрабатывались в основном под влиянием Unix-подобных систем. Поэтому Linux можно считать в большой степени POSIX-совместимым, а значит, мы можем искать в нём похожий вызов.
В этот раз мне не нужно читать документацию в интернете. К моим услугам команда man, которая показывает документацию по использованию других команд.
Запускаем в контейнере Ubuntu команду man exec и вновь открываем мануал. В Linux exec это целое семейство вызовов, но тут есть и глобальная переменная environ, и передача окружения через указатель envp. Но, чтобы нам подсмотреть в strace нужный вызов, нам надо знать его имя, судя по тому же man exec, это должен быть вызов execve. Благодаря POSIX-стандартам механизмы работы окружения в Linux очень похожи на те же механизмы в Unix V7.
Теперь запустим команду cat из Bash shell, чтобы увидеть параметры вызова:
Да, третьим аргументом передаётся список переменных, на него ссылается указатель 0xaaaadacacb50. Что там внутри у этого списка? Надо перезапустить strace с ключом -v:
Вот и наши знакомые переменные окружения в третьем параметре: PID, HOSTNAME, HOME и другие. Получается, что тут мало что отличается от вызова exec() времён Unix седьмой версии. Ну разве что не надо считать аргументы.
Продолжим расследование. В описании вызовов нет подсказок, где найти переменные окружения в памяти запущенного процесса. Но посмотреть интересно, ведь вот оно, то самое окружение, о котором мы так много говорим.
У меня есть подсказка — адрес указателя из strace — 0xaaaadacacb50. Посмотрим, что хранится по этому указателю в памяти bash:
Ого, да это как раз и есть указатель на начало окружения процесса bash. Мы можем попутешествовать по адресам этих указателей и увидеть, что они содержат наши переменные окружения:
Теперь, кажется, пришла пора остановиться.
Вместо итогов
Я давно работаю в консоли, поэтому переменные окружения — привычный и тривиальный инструмент. Для меня было открытием, как давно им пользуются в неизменном виде.
Первые версии Unix уже давно не используют, но именно они заложили фундамент операционных систем, который можно найти на своём компьютере с macOS или Linux. Потребуется только любопытство, старые версии мануалов и понимание того, куда смотреть.
Хочу сказать спасибо стараниям тех, кто сохраняет и выкладывает в общий доступ документацию старых версий приложений. Без них у меня бы не получилось это маленькое исследование.
Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!