- бумп до 2 откачен: копий в природе нет, читать по версии 1 пока нечему, и номер сжигать незачем - вместо истории версий записано правило: номер двигается, когда изменение формы способно изменить чтение уже разданной копии; правки формы до раздачи копий его не двигают, а смена словаря под другой язык — не двигает никогда
299 lines
23 KiB
Markdown
299 lines
23 KiB
Markdown
---
|
||
prefix: CONF
|
||
---
|
||
|
||
# Конфигурация приложения
|
||
|
||
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
||
секретами и когда падает.
|
||
|
||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 —
|
||
тогда и только тогда, когда написаны заглавными.
|
||
|
||
## Область действия
|
||
|
||
Правила написаны для приложений, которые мы пишем сами: только там мы
|
||
управляем тем, как конфигурация читается. Сторонний образ, живущий на
|
||
переменных окружения, вне области действия — это не повод отказываться от
|
||
конвенции для своих приложений.
|
||
|
||
## Правила
|
||
|
||
### CONF-1. Конфигурация — файл, а не окружение
|
||
|
||
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
|
||
окружения источником конфигурации не служат.
|
||
|
||
**ПОЧЕМУ.** Три довода, по убыванию веса:
|
||
|
||
- **Один типизированный источник.** Файл несёт секции, комментарии,
|
||
единицы измерения и валидируется целиком. Окружение — плоский набор
|
||
нетипизированных строк, который приходится документировать отдельно;
|
||
появление второго канала конфигурации гарантирует расхождение между ними.
|
||
- **Окружение наследуется дочерними процессами.** Всё, что приложение
|
||
запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию
|
||
секретов, хотя они ему не нужны.
|
||
- **В контейнере окружение расползается по лишним поверхностям.**
|
||
`docker inspect` показывает его любому, у кого есть доступ к сокету
|
||
докера; переменные оседают в compose-файле и `.env` на диске — то есть
|
||
файл всё равно появляется, только без структуры и валидации.
|
||
|
||
Обратите внимание, чего в этих доводах **нет**: `/proc/<pid>/environ` не
|
||
является аргументом — он имеет права `0400` и защищён проверкой
|
||
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
|
||
`0600`.
|
||
|
||
### CONF-2. Формат конфигурации — текстовый, с секциями и комментариями
|
||
|
||
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
|
||
|
||
**ПОЧЕМУ.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг
|
||
вообще читают; формат, в котором комментарий негде разместить, делает CONF-9
|
||
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
|
||
плоский список пар такой возможности не даёт и возвращает нас к тем же
|
||
свойствам, из-за которых отвергнуто окружение (CONF-1).
|
||
|
||
### CONF-3. Имя файла фиксировано, путь переопределяется опцией
|
||
|
||
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
|
||
задаётся опцией командной строки.
|
||
|
||
**ПОЧЕМУ.** Запуск без аргументов работает одинаково в разработке, в
|
||
контейнере и на сервере, и способ запуска не приходится помнить отдельно
|
||
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
|
||
(тесты, второй инстанс): без неё их разводят переменной окружения — тем
|
||
самым каналом, который закрывает CONF-1.
|
||
|
||
### CONF-20. Отсутствие файла конфигурации — ошибка старта
|
||
|
||
**ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в
|
||
рабочей директории (CONF-3), приложение не стартует: сообщение называет
|
||
искомый путь, код возврата ненулевой.
|
||
|
||
**ПОЧЕМУ.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что
|
||
развёртывание не довело работу до конца, а не что приложение попросили
|
||
работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал
|
||
**неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается
|
||
чаще всего.
|
||
|
||
Старт без файла ничего не спасает: у приложения с обязательными полями или
|
||
секретами всё равно упадёт валидация (CONF-18), только вместо одного сообщения
|
||
«нет `config.toml`» получится каскад «поле пусто», за которым настоящая
|
||
причина — деплой не отрендерил файл — не видна.
|
||
|
||
Приложение, которое запускается вообще без конфигурации, этой конвенцией не
|
||
описывается: это отдельный случай и отдельная конвенция.
|
||
|
||
### CONF-4. В репозитории лежит образец, а не рабочий конфиг
|
||
|
||
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
|
||
|
||
**ПОЧЕМУ.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет,
|
||
попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
|
||
закоммиченный конфиг конкретной среды становится вторым источником истины:
|
||
он расходится с тем, что реально развёрнуто, и расходится молча.
|
||
|
||
### CONF-5. Конфиг разбирается один раз при старте
|
||
|
||
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
|
||
файла конфигурации в бизнес-коде нет.
|
||
|
||
**ПОЧЕМУ.** Второе место чтения — это второй момент времени: две части кода
|
||
начинают видеть разные значения одного параметра, и расхождение не
|
||
воспроизводится, потому что зависит от того, когда файл потрогали.
|
||
Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17),
|
||
где она видна сразу, а не в первый вызов ветки, которая это поле читает.
|
||
|
||
### CONF-6. Конфиг неизменяем после старта
|
||
|
||
**ДОЛЖЕН.** Смена параметров — рестарт процесса.
|
||
|
||
**ПОЧЕМУ.** Изменяемый конфиг делает поведение функцией момента: один
|
||
запрос обслуживается наполовину старыми, наполовину новыми значениями, а
|
||
разбор инцидента требует знать хронологию правок файла, а не его текущее
|
||
содержимое.
|
||
|
||
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
||
умолчание.
|
||
|
||
### CONF-7. Умолчания живут в коде
|
||
|
||
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
|
||
|
||
**ПОЧЕМУ.** Умолчание, живущее в образце, действует только для тех, кто
|
||
образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно
|
||
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
|
||
поведение для неполного конфига и одно место, где это значение меняется.
|
||
|
||
### CONF-8. Образец перечисляет все поля
|
||
|
||
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
|
||
которых есть умолчание (CONF-7).
|
||
|
||
**ПОЧЕМУ.** Поле, живущее только в коде, для читателя конфига не
|
||
существует: он не знает, что параметр вообще можно менять, и добивается
|
||
нужного поведения обходным путём. Полнота образца — цена, которой CONF-7
|
||
покупает себе видимость.
|
||
|
||
### CONF-9. У каждого поля образца есть комментарий
|
||
|
||
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
|
||
|
||
- **зачем** поле — что оно меняет в поведении;
|
||
- **диапазон или допустимые значения** — перечисление либо границы;
|
||
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
|
||
`0–1`.
|
||
|
||
**ПОЧЕМУ.** Так конфиг читается без открывания кода — этим он и полезен;
|
||
без комментария читатель всё равно идёт в код, и образец перестаёт быть
|
||
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
|
||
дают валидное значение и работающий процесс, а ошибка обнаруживается по
|
||
последствиям — таймаут в тысячу раз не тот.
|
||
|
||
### CONF-10. Обязательность полей определяется дискриминатором `type`
|
||
|
||
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
|
||
бекенда или внешнего сервиса), валидация идёт по его значению:
|
||
|
||
| № | Значение `type` | Валидация |
|
||
|---|---|---|
|
||
| CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
|
||
| CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
|
||
|
||
**ПОЧЕМУ.** Фиксированный на секцию набор обязательных полей оставляет
|
||
выбор из двух плохих: заполнять поля бекенда, который не используется, или
|
||
не проверять обязательность вовсе — то есть выключить валидацию ровно там,
|
||
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
|
||
значений (CONF-10.2) нужно потому, что опечатка в `type` иначе неотличима от
|
||
неподдерживаемого варианта, и за списком приходится идти в код.
|
||
|
||
### CONF-11. Образец показывает все варианты `type`
|
||
|
||
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
|
||
альтернативные — блоками-комментариями ниже, каждый со своим описанием
|
||
полей.
|
||
|
||
**ПОЧЕМУ.** Иначе набор вариантов виден только из кода валидации, и образец
|
||
теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор
|
||
действительно есть. Закомментированный блок вдобавок переключается правкой
|
||
на месте, а не сборкой секции с нуля по документации.
|
||
|
||
### CONF-12. Секреты в конфиг приносит деплой
|
||
|
||
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
|
||
отдельного слоя секретов в приложении нет.
|
||
|
||
**ПОЧЕМУ.** Источник истины секрета — внешнее хранилище деплоя, не
|
||
репозиторий и не окружение. Любой второй канал — переменная окружения рядом
|
||
с файлом, собственный клиент к хранилищу внутри приложения — возвращает
|
||
вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при
|
||
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
|
||
особенного.
|
||
|
||
### CONF-13. Рендеренный конфиг — `0600` и владелец-рантайм
|
||
|
||
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
|
||
работает процесс.
|
||
|
||
**ПОЧЕМУ.** После CONF-1 и CONF-12 файл конфигурации — единственная
|
||
поверхность, на которой секреты лежат, и весь довод «файл вместо окружения»
|
||
держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты
|
||
шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на
|
||
другую.
|
||
|
||
### CONF-14. В образце секретные поля — пустые строки
|
||
|
||
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
|
||
пример.
|
||
|
||
**ПОЧЕМУ.** Правдоподобная заглушка доезжает до продакшена как настоящее
|
||
значение: шаблон отрендерился криво, поле осталось от образца, и проверка
|
||
непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг
|
||
механически отличимым от заполненного.
|
||
|
||
### CONF-15. Загрузчик проверяет, что обязательные секреты не пусты
|
||
|
||
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
|
||
|
||
**ПОЧЕМУ.** Это ловит криво отрендеренный шаблон до того, как он превратится
|
||
в 401 от внешнего API через час работы, — то есть в момент, когда причина
|
||
ещё очевидна и связана с деплоем.
|
||
|
||
### CONF-16. Секреты не попадают в логи
|
||
|
||
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
|
||
одном уровне.
|
||
|
||
**ПОЧЕМУ.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они
|
||
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
|
||
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
|
||
записи. Типичный источник утечки — отладочный дамп разобранного конфига при
|
||
старте.
|
||
|
||
### CONF-17. Конфиг валидируется на старте, до приёма трафика
|
||
|
||
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
|
||
кодом; процесс не стартует «наполовину».
|
||
|
||
**ПОЧЕМУ.** Наполовину стартовавший процесс проходит проверку живости и
|
||
падает позже — на первом запросе, который трогает испорченный параметр, — и
|
||
падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен,
|
||
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
|
||
завершения, и приложение считается развёрнутым.
|
||
|
||
### CONF-18. Минимальный набор проверок
|
||
|
||
**ДОЛЖЕН.** Валидация покрывает как минимум:
|
||
|
||
| № | Что проверяется | Когда всплывёт без проверки |
|
||
|---|---|---|
|
||
| CONF-18.1 | обязательные поля заданы (непустота секретов — CONF-15) | в ветке, которая это поле читает |
|
||
| CONF-18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
|
||
| CONF-18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
|
||
| CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит |
|
||
| CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
|
||
|
||
**ПОЧЕМУ.** Список минимальный и собран по одному признаку — правый
|
||
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
|
||
потеряна, и диагностируется как дефект приложения. Проверка на старте
|
||
сводит их все к одному моменту и одному сообщению.
|
||
|
||
### CONF-19. Проблемы конфига показываются разом
|
||
|
||
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
|
||
списком, а не падает на первой.
|
||
|
||
**ПОЧЕМУ.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько
|
||
раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и
|
||
деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля
|
||
одного источника: разом они читаются как одна причина, по одной — как
|
||
череда несвязанных мелочей.
|
||
|
||
### CONF-21. Значение поля в сообщении валидатора — по признаку секретности
|
||
|
||
**ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности
|
||
поля, которым уже пользуются CONF-15 и CONF-16:
|
||
|
||
| № | Поле | В сообщении |
|
||
|---|---|---|
|
||
| CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» |
|
||
| CONF-21.2 | секретное | имя поля и суть нарушения, без значения |
|
||
|
||
**ПОЧЕМУ.** Сообщение без значения отправляет читателя в файл — сличать
|
||
глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд»
|
||
или пробел в конце значения из такого сообщения не читаются вовсе. Значение
|
||
секретного поля при этом печатать некуда: вывод старта уходит в лог
|
||
супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же
|
||
канал утечки, который закрывает CONF-16. Отдельный список «что не печатать»
|
||
не заводится: признак один на CONF-15, CONF-16 и CONF-21, а второй список
|
||
разошёлся бы с первым — и поле оказалось бы секретным для логов, но
|
||
печатаемым валидатором.
|
||
|
||
## Связано
|
||
|
||
- конвенция `time` — формат времени; зона отображения — единственный
|
||
конфигурируемый параметр времени, семантика описана там.
|
||
- конвенция `app-directories` — конфиг лежит в категории «конфигурация» и
|
||
доступен приложению только на чтение.
|