Заставляем сервис php-fpm 5.6, запущенный через systemd, читать глобальные переменные окружения

Это короткий how-to для реализации конфигурации php-сервиса, зависимого от окружения, в котором он запущен. Я буду рад, если кто-то подскажет более изящное решение или поправит в мелочах.

Основная идея


Запускать сервис, микросервисы и зависимые приложения в рамках одной экосистемы, конфигурируемой с помощью переменных окружения.Проблема
В этой статье слишком много раз повторяется «переменные окружения».
Из коробки php-fpm игнорирует глобальные переменные окружения (getenv function), в то время как php cli их может получать.
Предыстория

Этот раздел можно пропустить, если вы уже работали с .env


В данный момент я работаю над проектом, написанном на ZF2. Для конфигурации проекта использовались конфиг-файлы для разных окружений. Это порождает большое количество дубликатов конфигурации в репозитории проекта примерно такого вида:

  • session.global.php
  • session.local.php.dist
  • session.unittest.php.dist
  • db.global.php
  • db.local.php.dist
  • db.unittest.php.dist
  • ...


Эти дубликаты приходится постоянно синхронизировать друг с другом. Кроме того, они хранят определённую php-логику внутри себя, что порождает дублирование кода.

Я добавил к проекту библиотеку, которая умеет считывать окружение из .env файла и загружать его в $_ENV (упрощённо).

Подключение библиотеки vlucas/phpdotenv к ZF2
composer require vlucas/phpdotenv
Открыть public/index.php
После require 'init_autoloader.php' добавить:
$dotenv = new Dotenv\Dotenv(__DIR__ . '/../');
// $dotenv->required('SOME_IMPORTANT'); // можно сделать некоторые переменные обязательными
$dotenv->load(); // можно использовать overload(), тогда файл .env станет более важен, чем глобальные переменные окружения (каскадный принцип)



Кроме того (это совершенно необязательно), добавил helper-функцию env() из laravel, которая является обёрткой над php getenv().

Добавляем метод env($key, $default = null) в ZF2
Создать файл, например library/Common/Config/env.php, с содержимым:
if ( ! function_exists('value'))
{
    /**
     * Return the default value of the given value.
     *
     * @param  mixed  $value
     * @return mixed
     */
    function value($value)
    {
        return $value instanceof Closure ? $value() : $value;
    }
}
if ( ! function_exists('env'))
{
    /**
     * Gets the value of an environment variable. Supports boolean, empty and null.
     *
     * @param  string  $key
     * @param  mixed   $default
     * @return mixed
     */
    function env($key, $default = null)
    {
        $value = getenv($key);

        if ($value === false) return value($default);

        switch (strtolower($value))
        {
            case 'true':
            case '(true)':
                return true;

            case 'false':
            case '(false)':
                return false;

            case 'empty':
            case '(empty)':
                return '';

            case 'null':
            case '(null)':
                return;
        }

        return $value;
    }
}

В composer.json добавить в секцию «autoload»:

"autoload" : {
        ...,
        "files": ["library/Common/Config/env.php"]
    },


Затем выполнить composer dumpautoload.


Этот шаг позволил выбросить из репозитория все лишние дубликаты конфиг-файлов (local.php, unittest.php, *.php.dist). Вместо этого в корне проекта появился .env.global со списком всех доступных переменных, которые задействованы в конфигах.

Как теперь настраивать конфигурацию?
Не окружение знает о переменных сервиса, а сервис учитывает окружение, в котором он запущен.
1. Т.к. на рабочей машине в переменных окружения может ничего и нет, да и обмениваться проектом неудобно между разными машинами, библиотека phpdotenv перед запуском приложения считывает .env файл и загоняет его переменные в $_ENV[$name] = $value.
2. Конфигурационные файлы вызывают метод env(), который является обёрткой над php-функцией getenv(), и читает переменные окружения, подставляя значение по-умолчанию по необходимости.
// Примеры использования:
$config['emails']['from'] = env('APP_EMAIL', 'info@myemail.com');
$config['is_production'] = ( 'production' == env('APP_ENV') );
if (env('ZF_DEBUG_TOOLS', false)) {
    $config['modules'][] = 'ZendDeveloperTools';
}


3. Файл .env не обязательно заполнять. Можно использовать глобальные переменные окружения или значения по-умолчанию в конфигурации. При отсутствии .env файла бросается exception (особенность библиотеки, не самая правильная), на production сервере её можно вообще не подключать. Для избежания exception, файл необходимо просто создать в корне проекта (touch .env).
4. Файл .env не обязательно должен хранить все доступные переменные проекта. Если в конфигах устанавливаются значения по-умолчанию, достаточно записывать в .env только переменные, отличающиеся в данном окружении.
5. Файл .env не нужно коммитить в репозиторий. Его следует добавить в ignore для системы контроля версий.
6. Чтобы сделать переменную окружения обязательной, в index.php необходимо добавить такую конструкцию:
$dotenv->required('APP_ENV'); // Переменная APP_ENV после этого должна в обязательном порядке быть установленной через .env или через окружение

7. В репозитории проекта можно коммитить файлы вида .env.* (.env.phpunit, .env.develop). Это не что иное, как закладки с набором переменных для разного окружения. Оркестратор или CI-система просто копирует шаблон (или переменные из него) при разворачивании проекта там, где проект разворачивается в нескольких копиях в рамках одной системы или нет возможности оперировать глобальными переменными окружения. Закладки удобно сравнивать друг с другом. Эти закладки никак не участвуют в логике сервиса.

Важно: .env.production не должен храниться в репозитории проекта.
Удобно создать .env.default – файл, который содержит все переменные окружения, поддерживаемые в проекте на текущий момент (максимально-возможный template для .env).

Так что, теперь все конфиги нужно дублировать в .env? Когда добавлять новую переменную окружения?

Выносить конфигурацию в окружение стоит в том случае, если эта опция может отличаться на разных хостах.
Нет ничего плохого в том, чтобы записать нечто в переменные среды.

$config['csv_separator] = ' | '; // это не меняется в разном окружении, не стоит это трогать.
$config['like_panel'] = true; // а это меняется, можно заменить на env('APP_LIKE_PANEL', false)
$config['facebook_app_id'] = 88888888881; // рекомендуется использовать переменные среды

А как быть с паролями и чувствительными данными?

Храните production-данные в отдельном репозитории, в хранилище паролей или, например, в защищенном хранилище оркестратора.

Итак, проект теперь учитывает окружение, но...


Пока разработка велась на рабочих машинках, проект читал .env файл и всё работало. Но когда я развернул тестовую среду, оказалось, что если задать взаправдашние системные переменные окружения, php-fpm их игнорирует. Различные рецепты из гугла и StackOverflow сводились к той или иной автоматизации использования двух известных способов:

1. Передача переменных через nginx параметром fastcgi_param SOMEENV test;
2. Установкой переменных в формате env[SOME_VAR] в конфигурации пула рабочих процессов php-fpm.

И первый, и второй вариант, удобны для каких-то особых ситуаций. Но если мыслить в парадигме «конфигурировать среду, а не приложение», то подобные способы оказываются куда труднее, чем например просто положить .env файл в папку с проектом. Но ведь оркестратор, CI-система или просто системный администратор не должен знать детали реализации проекта, это не изящно.

Предлагаемый способ решения
Скомбинировав различные рецепты из сети, я нащупал следующее рабочее решение.
Тестировалось под Centos 7, PHP 5.6.14.

1. Открыть /etc/php.ini

- Заменить
variables_order = "GPCS" 
на 
variables_order = "EGPCS"
# После этого PHP добавит в глобальное пространство переменные окружения
# http://php.net/manual/ru/ini.core.php#ini.variables-order

2. Открыть /etc/php-fpm.d/www.conf, не путать с /etc/php-fpm.conf (в разных системах может быть в разном месте, это конфиг www-пула процессов для php-fpm.

- Добавить (или заменить, если вдруг есть):
clear_env = no # выключить очистку глобальных переменных для запускаемых воркеров

3. Установить необходимые переменные окружения в /etc/environment (стандартный синтаксис A=B)

4. ln -fs /etc/environment /etc/sysconfig/php-fpm # теперь конфиг переменных окружения сервиса php-fpm будет просто ссылкой на глобальный конфиг

5. systemctl daemon-reload && service php-fpm restart

Этот же подход с симлинком, в теории, применим и к другим сервисам.

Плюсы предложенного решения:
— Переменные, хранящиеся в /etc/environment, доступны разным приложениям. Можно вызвать echo $MYSQL_HOST в shell или getenv('MYSQL_HOST') в php.
— Переменные окружения, которые явно не заданы в /etc/environment, не попадут в php-fpm. Это позволяет с помощью оркестратора контролировать окружение извне изолированной системы, в которой запущен сервис.

Минусы:
— К сожалению, у php-fpm я не нашел работающей команды для reload по аналогии с nginx, так что в случае изменения /etc/environment, обязательно нужно делать systemctl daemon-reload && service php-fpm restart.

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

Ссылки:
— Для тех, кто не читал статью
— Методология двенадцати факторов разработки SAAS: храните конфигурацию в окружении (англ.)
— Загрузка переменных окружения с помощью .env-файлов для development environment в php-проектах.

© Habrahabr.ru