# Конфигурация приложения Как устроена конфигурация: где лежит, как попадает в процесс, что с секретами и когда падает. Форма записи — `LANGUAGE.md`. ## Область действия Правила написаны для приложений, которые мы пишем сами: только там мы управляем тем, как конфигурация читается. Сторонний образ, живущий на переменных окружения, вне области действия — это не повод отказываться от конвенции для своих приложений. ## Правила ### R1. Конфигурация — файл, а не окружение **ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные окружения источником конфигурации не служат. **Почему.** Три довода, по убыванию веса: - **Один типизированный источник.** Файл несёт секции, комментарии, единицы измерения и валидируется целиком. Окружение — плоский набор нетипизированных строк, который приходится документировать отдельно; появление второго канала конфигурации гарантирует расхождение между ними. - **Окружение наследуется дочерними процессами.** Всё, что приложение запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию секретов, хотя они ему не нужны. - **В контейнере окружение расползается по лишним поверхностям.** `docker inspect` показывает его любому, у кого есть доступ к сокету докера; переменные оседают в compose-файле и `.env` на диске — то есть файл всё равно появляется, только без структуры и валидации. Обратите внимание, чего в этих доводах **нет**: `/proc//environ` не является аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под `0600`. ### R2. Формат конфигурации — текстовый, с секциями и комментариями **СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. **Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг вообще читают; формат, в котором комментарий негде разместить, делает R9 невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — плоский список пар такой возможности не даёт и возвращает нас к тем же свойствам, из-за которых отвергнуто окружение (R1). ### R3. Имя файла фиксировано, путь переопределяется опцией **СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь задаётся опцией командной строки. **Почему.** Запуск без аргументов работает одинаково в разработке, в контейнере и на сервере, и способ запуска не приходится помнить отдельно для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько (тесты, второй инстанс): без неё их разводят переменной окружения — тем самым каналом, который закрывает R1. ### R20. Отсутствие файла конфигурации — ошибка старта **ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в рабочей директории (R3), приложение не стартует: сообщение называет искомый путь, код возврата ненулевой. **Почему.** Конфиг — артефакт деплоя (R12), и его отсутствие означает, что развёртывание не довело работу до конца, а не что приложение попросили работать на умолчаниях. Умолчания (R7) существуют, чтобы работал **неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается чаще всего. Старт без файла ничего не спасает: у приложения с обязательными полями или секретами всё равно упадёт валидация (R18), только вместо одного сообщения «нет `config.toml`» получится каскад «поле пусто», за которым настоящая причина — деплой не отрендерил файл — не видна. Приложение, которое запускается вообще без конфигурации, этой конвенцией не описывается: это отдельный случай и отдельная конвенция. ### R4. В репозитории лежит образец, а не рабочий конфиг **НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. **Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет, попавший в историю, чинится ротацией, а не удалением файла. Кроме того, закоммиченный конфиг конкретной среды становится вторым источником истины: он расходится с тем, что реально развёрнуто, и расходится молча. ### R5. Конфиг разбирается один раз при старте **ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения файла конфигурации в бизнес-коде нет. **Почему.** Второе место чтения — это второй момент времени: две части кода начинают видеть разные значения одного параметра, и расхождение не воспроизводится, потому что зависит от того, когда файл потрогали. Типизированная структура вдобавок переносит ошибку формата в старт (R17), где она видна сразу, а не в первый вызов ветки, которая это поле читает. ### R6. Конфиг неизменяем после старта **ДОЛЖЕН.** Смена параметров — рестарт процесса. **Почему.** Изменяемый конфиг делает поведение функцией момента: один запрос обслуживается наполовину старыми, наполовину новыми значениями, а разбор инцидента требует знать хронологию правок файла, а не его текущее содержимое. Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не умолчание. ### R7. Умолчания живут в коде **ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. **Почему.** Умолчание, живущее в образце, действует только для тех, кто образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое поведение для неполного конфига и одно место, где это значение меняется. ### R8. Образец перечисляет все поля **ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у которых есть умолчание (R7). **Почему.** Поле, живущее только в коде, для читателя конфига не существует: он не знает, что параметр вообще можно менять, и добивается нужного поведения обходным путём. Полнота образца — цена, которой R7 покупает себе видимость. ### R9. У каждого поля образца есть комментарий **ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно: - **зачем** поле — что оно меняет в поведении; - **диапазон или допустимые значения** — перечисление либо границы; - **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля `0–1`. **Почему.** Так конфиг читается без открывания кода — этим он и полезен; без комментария читатель всё равно идёт в код, и образец перестаёт быть справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды дают валидное значение и работающий процесс, а ошибка обнаруживается по последствиям — таймаут в тысячу раз не тот. ### R10. Обязательность полей определяется дискриминатором `type` **ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор бекенда или внешнего сервиса), валидация идёт по его значению: | № | Значение `type` | Валидация | |---|---|---| | R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | | R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | **Почему.** Фиксированный на секцию набор обязательных полей оставляет выбор из двух плохих: заполнять поля бекенда, который не используется, или не проверять обязательность вовсе — то есть выключить валидацию ровно там, где вариантов много и ошибиться легче всего. Перечисление поддерживаемых значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от неподдерживаемого варианта, и за списком приходится идти в код. ### R11. Образец показывает все варианты `type` **СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями, альтернативные — блоками-комментариями ниже, каждый со своим описанием полей. **Почему.** Иначе набор вариантов виден только из кода валидации, и образец теряет свойство справочника (R8, R9) ровно на той секции, где выбор действительно есть. Закомментированный блок вдобавок переключается правкой на месте, а не сборкой секции с нуля по документации. ### R12. Секреты в конфиг приносит деплой **ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; отдельного слоя секретов в приложении нет. **Почему.** Источник истины секрета — внешнее хранилище деплоя, не репозиторий и не окружение. Любой второй канал — переменная окружения рядом с файлом, собственный клиент к хранилищу внутри приложения — возвращает вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при этом остаётся тривиальным: оно читает файл и про секреты не знает ничего особенного. ### R13. Рендеренный конфиг — `0600` и владелец-рантайм **ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого работает процесс. **Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на которой секреты лежат, и весь довод «файл вместо окружения» держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало бы окружение, — и тогда R1 меняет одну утечку на другую. ### R14. В образце секретные поля — пустые строки **ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не пример. **Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее значение: шаблон отрендерился криво, поле осталось от образца, и проверка непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг механически отличимым от заполненного. ### R15. Загрузчик проверяет, что обязательные секреты не пусты **ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. **Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится в 401 от внешнего API через час работы, — то есть в момент, когда причина ещё очевидна и связана с деплоем. ### R16. Секреты не попадают в логи **НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на одном уровне. **Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они собираются, пересылаются и попадают в бэкапы, где права исходного файла уже ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением записи. Типичный источник утечки — отладочный дамп разобранного конфига при старте. ### R17. Конфиг валидируется на старте, до приёма трафика **ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым кодом; процесс не стартует «наполовину». **Почему.** Наполовину стартовавший процесс проходит проверку живости и падает позже — на первом запросе, который трогает испорченный параметр, — и падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен, чтобы неудачный старт увидел супервизор: без него он неотличим от штатного завершения, и приложение считается развёрнутым. ### R18. Минимальный набор проверок **ДОЛЖЕН.** Валидация покрывает как минимум: | № | Что проверяется | Когда всплывёт без проверки | |---|---|---| | R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает | | R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте | | R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке | | R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит | | R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | **Почему.** Список минимальный и собран по одному признаку — правый столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже потеряна, и диагностируется как дефект приложения. Проверка на старте сводит их все к одному моменту и одному сообщению. ### R19. Проблемы конфига показываются разом **ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним списком, а не падает на первой. **Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля одного источника: разом они читаются как одна причина, по одной — как череда несвязанных мелочей. ### R21. Значение поля в сообщении валидатора — по признаку секретности **ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности поля, которым уже пользуются R15 и R16: | № | Поле | В сообщении | |---|---|---| | R21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» | | R21.2 | секретное | имя поля и суть нарушения, без значения | **Почему.** Сообщение без значения отправляет читателя в файл — сличать глазами каждую строку списка R19; ошибки вида «секунды вместо миллисекунд» или пробел в конце значения из такого сообщения не читаются вовсе. Значение секретного поля при этом печатать некуда: вывод старта уходит в лог супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же канал утечки, который закрывает R16. Отдельный список «что не печатать» не заводится: признак один на R15, R16 и R21, а второй список разошёлся бы с первым — и поле оказалось бы секретным для логов, но печатаемым валидатором. ## Связано - `arch/time.md` — формат времени; зона отображения — единственный конфигурируемый параметр времени, семантика описана там. - `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и доступен приложению только на чтение.