остальные конвенции переведены на формальный язык

- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у
  каждого модальность и обязательный блок «Почему»
- классифицирующие места оформлены таблицами, файловый статус снят
  отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
av
2026-07-25 19:17:32 +03:00
parent 7701a28df1
commit 31d0620f55
11 changed files with 2404 additions and 811 deletions
+200 -64
View File
@@ -1,15 +1,23 @@
---
status: рекомендуемая
---
# Конфигурация приложения
Как устроена конфигурация: где лежит, как попадает в процесс, что с
секретами и когда падает.
секретами и когда падает. Форма записи — `common/language.md`.
## Файл, а не окружение
## Область действия
**Конфигурация — файл.** Причины, по убыванию веса:
Правила написаны для приложений, которые мы пишем сами: только там мы
управляем тем, как конфигурация читается. Сторонний образ, живущий на
переменных окружения, вне области действия — это не повод отказываться от
конвенции для своих приложений.
## Правила
### R1. Конфигурация — файл, а не окружение
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
окружения источником конфигурации не служат.
**Почему.** Три довода, по убыванию веса:
- **Один типизированный источник.** Файл несёт секции, комментарии,
единицы измерения и валидируется целиком. Окружение — плоский набор
@@ -23,94 +31,222 @@ status: рекомендуемая
докера; переменные оседают в compose-файле и `.env` на диске — то есть
файл всё равно появляется, только без структуры и валидации.
Обратите внимание, чего в списке **нет**: `/proc/<pid>/environ` не является
аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`,
то есть доступен ровно тому же кругу, что и файл под `0600`.
Обратите внимание, чего в этих доводах **нет**: `/proc/<pid>/environ` не
является аргументом — он имеет права `0400` и защищён проверкой
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
`0600`.
Запрет держится на «один источник» и на том, что все приложения свои. Для
стороннего образа, живущего на env, конвенция неприменима — это не повод
отказываться от неё для своих.
### R2. Формат конфигурации — текстовый, с секциями и комментариями
Практика:
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку).
- Имя по умолчанию фиксировано и ищется в рабочей директории процесса;
путь переопределяется опцией командной строки.
- Реальный конфиг не коммитится. В репозитории лежит **образец**.
**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает R9
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
плоский список пар такой возможности не даёт и возвращает нас к тем же
свойствам, из-за которых отвергнуто окружение (R1).
## Грузим один раз, дальше не перечитываем
### R3. Имя файла фиксировано, путь переопределяется опцией
- Разбор — **один раз при старте**, в одну типизированную структуру.
Дальше по коду читаем только её: чтения файла в бизнес-коде нет.
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
умолчание.
- Умолчания задаются в коде, файл их перекрывает. Образец при этом
перечисляет **все** поля, включая те, у которых есть умолчание: поле,
живущее только в коде, для читателя конфига не существует.
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
задаётся опцией командной строки.
## Образец самодокументируем
**Почему.** Запуск без аргументов работает одинаково в разработке, в
контейнере и на сервере, и способ запуска не приходится помнить отдельно
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
(тесты, второй инстанс): без неё их разводят переменной окружения — тем
самым каналом, который закрывает R1.
Образец коммитим как единый справочник по конфигу: все секции и все поля.
**Каждое поле снабжаем комментарием**, из которого ясно:
### R4. В репозитории лежит образец, а не рабочий конфиг
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет,
попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
закоммиченный конфиг конкретной среды становится вторым источником истины:
он расходится с тем, что реально развёрнуто, и расходится молча.
### R5. Конфиг разбирается один раз при старте
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
файла конфигурации в бизнес-коде нет.
**Почему.** Второе место чтения — это второй момент времени: две части кода
начинают видеть разные значения одного параметра, и расхождение не
воспроизводится, потому что зависит от того, когда файл потрогали.
Типизированная структура вдобавок переносит ошибку формата в старт (R17),
где она видна сразу, а не в первый вызов ветки, которая это поле читает.
### R6. Конфиг неизменяем после старта
**ДОЛЖЕН.** Смена параметров — рестарт процесса.
**Почему.** Изменяемый конфиг делает поведение функцией момента: один
запрос обслуживается наполовину старыми, наполовину новыми значениями, а
разбор инцидента требует знать хронологию правок файла, а не его текущее
содержимое.
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
умолчание.
### R7. Умолчания живут в коде
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
**Почему.** Умолчание, живущее в образце, действует только для тех, кто
образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
поведение для неполного конфига и одно место, где это значение меняется.
### R8. Образец перечисляет все поля
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
которых есть умолчание (R7).
**Почему.** Поле, живущее только в коде, для читателя конфига не
существует: он не знает, что параметр вообще можно менять, и добивается
нужного поведения обходным путём. Полнота образца — цена, которой R7
покупает себе видимость.
### R9. У каждого поля образца есть комментарий
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
- **зачем** поле — что оно меняет в поведении;
- **диапазон или допустимые значения** — перечисление либо границы;
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
`01`.
Так конфиг читается без открывания кода — этим он и полезен.
**Почему.** Так конфиг читается без открывания кода — этим он и полезен;
без комментария читатель всё равно идёт в код, и образец перестаёт быть
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
дают валидное значение и работающий процесс, а ошибка обнаруживается по
последствиям — таймаут в тысячу раз не тот.
## Поля по дискриминатору `type`
### R10. Обязательность полей определяется дискриминатором `type`
Когда набор полей секции зависит от поля-дискриминатора (выбор одного из
бекендов или внешних сервисов), обязательность полей определяется его
значением, а не фиксирована для секции.
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
бекенда или внешнего сервиса), валидация идёт по его значению:
- **Валидация — по значению `type`**: для каждого поддерживаемого варианта
свой набор обязательных полей; поля других вариантов не требуются.
Неизвестное значение → ошибка на старте с перечислением поддерживаемых.
- **Образец — по значению `type`**: основной вариант предзаполнен рабочими
значениями, альтернативные — блоками-комментариями ниже, каждый со своим
описанием полей. Из примера видны все варианты, не открывая код.
| № | Значение `type` | Валидация |
|---|---|---|
| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
## Секреты приносит деплой
**Почему.** Фиксированный на секцию набор обязательных полей оставляет
выбор из двух плохих: заполнять поля бекенда, который не используется, или
не проверять обязательность вовсе — то есть выключить валидацию ровно там,
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от
неподдерживаемого варианта, и за списком приходится идти в код.
Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя
секретов в приложении нет — оно просто читает файл. Источник истины
секрета — внешнее хранилище деплоя, не репозиторий и не окружение.
### R11. Образец показывает все варианты `type`
- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime-
пользователь.
- В образце секретные поля — пустые строки.
- Загрузчик на старте проверяет, что обязательные секреты не пусты: это
ловит криво отрендеренный шаблон до того, как он превратится в 401 от
внешнего API через час работы.
- В логи секреты не попадают.
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
альтернативные — блоками-комментариями ниже, каждый со своим описанием
полей.
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец
теряет свойство справочника (R8, R9) ровно на той секции, где выбор
действительно есть. Закомментированный блок вдобавок переключается правкой
на месте, а не сборкой секции с нуля по документации.
### R12. Секреты в конфиг приносит деплой
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
отдельного слоя секретов в приложении нет.
**Почему.** Источник истины секрета — внешнее хранилище деплоя, не
репозиторий и не окружение. Любой второй канал — переменная окружения рядом
с файлом, собственный клиент к хранилищу внутри приложения — возвращает
вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
особенного.
### R13. Рендеренный конфиг — `0600` и владелец-рантайм
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
работает процесс.
**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на
которой секреты лежат, и весь довод «файл вместо окружения» держится на его
правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало
бы окружение, — и тогда R1 меняет одну утечку на другую.
### R14. В образце секретные поля — пустые строки
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
пример.
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее
значение: шаблон отрендерился криво, поле осталось от образца, и проверка
непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг
механически отличимым от заполненного.
### R15. Загрузчик проверяет, что обязательные секреты не пусты
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится
в 401 от внешнего API через час работы, — то есть в момент, когда причина
ещё очевидна и связана с деплоем.
### R16. Секреты не попадают в логи
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
одном уровне.
**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
записи. Типичный источник утечки — отладочный дамп разобранного конфига при
старте.
<!-- local:секретные-поля -->
<!-- /local -->
## Валидация и fail-fast
### R17. Конфиг валидируется на старте, до приёма трафика
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину».
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
кодом; процесс не стартует «наполовину».
Проверяем как минимум:
**Почему.** Наполовину стартовавший процесс проходит проверку живости и
падает позже — на первом запросе, который трогает испорченный параметр, — и
падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен,
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
завершения, и приложение считается развёрнутым.
- обязательные поля заданы, обязательные секреты не пусты;
- пути существуют и доступны на запись/чтение по назначению;
- числовые диапазоны и единицы (доли, таймауты, счётчики попыток);
- строки, которые парсятся во что-то (длительности, зоны, URL), реально
парсятся;
- включённые секции консистентны: если интеграция включена — заданы все её
обязательные поля.
### R18. Минимальный набор проверок
Проблемы собираем и показываем **разом**, а не по одной за запуск.
**ДОЛЖЕН.** Валидация покрывает как минимум:
| № | Что проверяется | Когда всплывёт без проверки |
|---|---|---|
| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает |
| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL), реально парсятся | при первом обращении к тому, что за ними стоит |
| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
**Почему.** Список минимальный и собран по одному признаку — правый
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
потеряна, и диагностируется как дефект приложения. Проверка на старте
сводит их все к одному моменту и одному сообщению.
<!-- local:проверки -->
<!-- /local -->
### R19. Проблемы конфига показываются разом
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
списком, а не падает на первой.
**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько
раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и
деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля
одного источника: разом они читаются как одна причина, по одной — как
череда несвязанных мелочей.
## Связано
- `arch/time.md` — формат времени; зона отображения — единственный