остальные конвенции переведены на формальный язык
- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у каждого модальность и обязательный блок «Почему» - классифицирующие места оформлены таблицами, файловый статус снят отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
+137
-80
@@ -1,89 +1,146 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Категории директорий приложения
|
||||
|
||||
Всё, что приложение пишет на диск, делится на три категории по принципу
|
||||
создания и ценности содержимого:
|
||||
|
||||
- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе
|
||||
секреты;
|
||||
- **данные** — то, что генерирует приложение и что нужно бэкапить;
|
||||
- **кеш** — то, что генерирует приложение и что не нужно бэкапить:
|
||||
приложение перегенерирует заново.
|
||||
|
||||
Цель — упростить оперирование данными. Категория сразу отвечает на два
|
||||
вопроса, которые иначе приходится выяснять по коду приложения: **кто
|
||||
создаёт** содержимое и **что будет, если его потерять**.
|
||||
|
||||
## Категории
|
||||
|
||||
| Категория | Директория | Создаёт | Потеря содержимого | Бэкап |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен |
|
||||
| Данные | `data/` | приложение | невосполнима | обязателен |
|
||||
| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен |
|
||||
|
||||
Имена в таблице — умолчание для случая «одна директория на категорию».
|
||||
**Категория может состоять из нескольких директорий**, и это нормально:
|
||||
крупные файлы отделяют от базы, чтобы двигать их между дисками независимо
|
||||
(`media/`, `uploads/` — та же категория «данные», что и `data/`).
|
||||
Принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
||||
|
||||
Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять
|
||||
приложение заново. Поднимется само и наверстает — кеш. Не поднимется или
|
||||
поднимется пустым — данные.
|
||||
|
||||
Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты,
|
||||
а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и
|
||||
хранилище секретов, а не снапшот бэкапа.
|
||||
|
||||
## Данные, которые нельзя копировать на живую
|
||||
|
||||
Файловый снапшот работающей СУБД не гарантирует консистентности:
|
||||
скопированный каталог может не восстановиться. Поэтому у категории «данные»
|
||||
есть два способа попасть в бэкап:
|
||||
|
||||
- **копированием** — если файлы самодостаточны на любой момент времени;
|
||||
- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда
|
||||
бэкапится директория дампов, а сырой каталог базы — нет.
|
||||
|
||||
Директория дампов — тоже данные, просто производные. Решение «копировать
|
||||
или дампить» принимается **при заведении приложения**, а не при первой
|
||||
неудачной попытке восстановления.
|
||||
|
||||
## Контракт с приложением
|
||||
|
||||
Категории — не только про деплой. Приложение **разводит свои записываемые
|
||||
пути по категориям в конфигурации**, а не складывает всё в один каталог:
|
||||
иначе категорию нельзя определить снаружи и список бэкапа приходится
|
||||
составлять вручную, читая код.
|
||||
|
||||
- Путь к БД, загруженным файлам, сгенерированным артефактам — данные.
|
||||
- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые
|
||||
перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠
|
||||
невосполнимо.
|
||||
- Приложение не пишет в директорию конфигурации: она может быть доступна
|
||||
только на чтение.
|
||||
|
||||
Если приложение не умеет разделять, это его дефект, а не повод смешивать
|
||||
категории в раскладке.
|
||||
|
||||
## Список бэкапа выводится, а не составляется
|
||||
|
||||
Список бэкапа получается из категорий по правилу: туда идут данные, не идут
|
||||
конфигурация и кеш. Правило механическое — но его применяет человек или
|
||||
шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что
|
||||
и создание директорий. Независимо набранный список — источник расхождения
|
||||
между тем, что бэкапится, и тем, что нужно.
|
||||
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
|
||||
отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
|
||||
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
|
||||
механически выводится состав бэкапа. Форма записи — `common/language.md`.
|
||||
|
||||
## Область действия
|
||||
|
||||
Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется
|
||||
к **новым приложениям**; существующие переезжают по мере касания, отдельной
|
||||
кампанией не переписываются. Разделять данные и кеш задним числом имеет
|
||||
смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы.
|
||||
Раскладка меняется вместе с миграцией данных, поэтому правила
|
||||
распространяются на **новые приложения**; существующие переезжают по мере
|
||||
касания, отдельной кампанией не переписываются. Разделять данные и кеш
|
||||
задним числом имеет смысл тогда, когда кеш заметен по объёму в бэкапе, а не
|
||||
ради самой схемы.
|
||||
|
||||
## Правила
|
||||
|
||||
### R1. Записываемые пути разложены по трём категориям
|
||||
|
||||
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
|
||||
относится к одной из трёх категорий:
|
||||
|
||||
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|
||||
|---|---|---|---|---|---|
|
||||
| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
|
||||
| R1.2 | данные | `data/` | приложение | невосполнима | да |
|
||||
| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
|
||||
|
||||
Имена в таблице — умолчание для случая «одна директория на категорию».
|
||||
|
||||
**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно
|
||||
снести при нехватке места, что переживает переезд на другой диск —
|
||||
читаются из категории, а не выясняются по коду приложения. Без единой
|
||||
классификации каждое такое решение принимается заново и каждый раз чуть
|
||||
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
|
||||
потерянные данные не стоят ничего, потому что их больше нет.
|
||||
|
||||
### R2. Категория может состоять из нескольких директорий
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
|
||||
принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
||||
|
||||
**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно
|
||||
три директории». Крупные файлы отделяют от базы, чтобы двигать их между
|
||||
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
|
||||
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном
|
||||
диске, либо выводить директорию из-под категорий вовсе. Категорию нельзя
|
||||
задавать именем ровно поэтому: имён в категории несколько, и выбираются они
|
||||
по содержимому.
|
||||
|
||||
### R3. Данные и кеш разделяются по тесту на пересоздание
|
||||
|
||||
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
|
||||
|
||||
| № | Что лежит | Категория |
|
||||
|---|---|---|
|
||||
| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
|
||||
| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
|
||||
| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
|
||||
|
||||
**Почему.** Без внешнего теста граница проводится по ощущению «жалко
|
||||
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
|
||||
переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и
|
||||
разделяет эти два свойства именно способность приложения пересоздать
|
||||
содержимое. Обратная ошибка — данные, названные кешем, — тестом
|
||||
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
|
||||
при попытке восстановить.
|
||||
|
||||
### R4. В бэкап идут данные, и только они
|
||||
|
||||
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
|
||||
кеш — нет.
|
||||
|
||||
**Почему.** Кеш раздувает снапшот содержимым, которое приложение
|
||||
восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там
|
||||
лежат секреты, а бэкапы уезжают в облако — источник истины для
|
||||
конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте.
|
||||
Ошибка в другую сторону дороже: директория данных, не попавшая в список,
|
||||
обнаруживается в единственный момент, когда исправить её уже нечем.
|
||||
|
||||
### R5. Список бэкапа ссылается на те же пути, что и создание директорий
|
||||
|
||||
**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же
|
||||
объявления путей, по которым директории создаются, а не набирается
|
||||
независимо.
|
||||
|
||||
**Почему.** Правило вывода механическое, но применяет его человек или
|
||||
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
|
||||
невозможным: переименование директории отражается в обоих местах сразу.
|
||||
Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа
|
||||
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
|
||||
нужно, проявляется при восстановлении.
|
||||
|
||||
### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
|
||||
|
||||
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
|
||||
|
||||
| № | Данные | В бэкап |
|
||||
|---|---|---|
|
||||
| R6.1 | файлы самодостаточны на любой момент времени | копированием |
|
||||
| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
|
||||
|
||||
**Почему.** Файловый снапшот работающей СУБД не гарантирует
|
||||
консистентности: скопированный каталог может не восстановиться, и узнают
|
||||
об этом при восстановлении. Директория дампов — тоже данные, просто
|
||||
производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из
|
||||
списка при этом исключается: он удваивает объём снапшота и добавляет к
|
||||
надёжной копии заведомо ненадёжную.
|
||||
|
||||
### R7. Способ выбирается при заведении приложения
|
||||
|
||||
**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда
|
||||
приложение заводят.
|
||||
|
||||
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не
|
||||
понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из
|
||||
которых база не поднимется. Отложить решение — значит принять его по факту
|
||||
первой неудачной попытки восстановления, то есть тогда, когда данных уже
|
||||
нет.
|
||||
|
||||
### R8. Приложение разводит записываемые пути по категориям
|
||||
|
||||
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
|
||||
кеша, а не один каталог на всё.
|
||||
|
||||
**Почему.** Снаружи категория определяется только тогда, когда разным
|
||||
категориям соответствуют разные директории. Всё, сложенное в один каталог,
|
||||
заставляет составлять список бэкапа вручную, читая код приложения, — и
|
||||
пересматривать его при каждом обновлении, потому что новый подкаталог
|
||||
появляется молча. Приложение, которое не умеет разделять, тем самым
|
||||
дефектно; раскладка под этот дефект не подстраивается.
|
||||
|
||||
### R9. Приложение не пишет в директорию конфигурации
|
||||
|
||||
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
|
||||
конфигурации.
|
||||
|
||||
**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому
|
||||
всё, что приложение туда записало, следующий деплой затирает без
|
||||
предупреждения. Вдобавок директория конфигурации может быть подключена
|
||||
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
|
||||
видно в момент, когда приложение настраивают.
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
+200
-64
@@ -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. У каждого поля образца есть комментарий
|
||||
|
||||
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
|
||||
|
||||
- **зачем** поле — что оно меняет в поведении;
|
||||
- **диапазон или допустимые значения** — перечисление либо границы;
|
||||
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
|
||||
`0–1`.
|
||||
|
||||
Так конфиг читается без открывания кода — этим он и полезен.
|
||||
**Почему.** Так конфиг читается без открывания кода — этим он и полезен;
|
||||
без комментария читатель всё равно идёт в код, и образец перестаёт быть
|
||||
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
|
||||
дают валидное значение и работающий процесс, а ошибка обнаруживается по
|
||||
последствиям — таймаут в тысячу раз не тот.
|
||||
|
||||
## Поля по дискриминатору `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` — формат времени; зона отображения — единственный
|
||||
|
||||
+144
-47
@@ -1,63 +1,160 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Время
|
||||
|
||||
Один формат времени на всё приложение: хранение, логи, API, обмен с
|
||||
внешними системами. Разные форматы в разных слоях — источник ошибок,
|
||||
которые всплывают через полгода на границе перехода на летнее время.
|
||||
Как приложение записывает моменты и длительности: в каком формате, откуда
|
||||
берётся значение и где появляется не-UTC. Форма записи —
|
||||
`common/language.md`.
|
||||
|
||||
## Формат
|
||||
## Область действия
|
||||
|
||||
- **RFC 3339, UTC, суффикс `Z`**: `2026-06-28T11:23:45Z`.
|
||||
- **Ширина фиксируется на каждый носитель** и внутри него не плавает.
|
||||
Лексикографическая сортировка равна хронологии только среди строк
|
||||
одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
|
||||
хронологически позже. Ради этого формат и фиксируется — `ORDER BY
|
||||
created_at` по текстовому полю обязан давать порядок событий.
|
||||
- Разные носители могут иметь разную точность: строки БД и строки лога
|
||||
между собой никогда не сравниваются. Требование — не «одна точность на
|
||||
приложение», а «внутри колонки и внутри потока логов ширина одна».
|
||||
- Локальное время не хранится и не передаётся **нигде** — ни в БД, ни в
|
||||
логах, ни в JSON API.
|
||||
Конвенция описывает фиксацию **свершившихся моментов** — того, что уже
|
||||
произошло и попало в базу, лог или ответ API. Планирование будущих событий —
|
||||
отдельный случай: там хранят локальное время плюс имя зоны, потому что
|
||||
правила зон меняются в промежутке между планированием и наступлением. Пока
|
||||
такой сущности нет, правил для неё в файле нет.
|
||||
|
||||
## Генерирует приложение, а не хранилище
|
||||
## Правила
|
||||
|
||||
- Единая точка получения «сейчас» и единая точка форматирования и разбора —
|
||||
как с идентификаторами (`arch/db-identifiers.md`). Прямые вызовы часов по
|
||||
коду не разбросаны: иначе ни формат, ни зона не гарантированы.
|
||||
- **Дефолты в схеме БД не используем.** Забытая вставка `created_at`
|
||||
должна падать громко, а не тихо получать значение от БД — иначе
|
||||
расходятся источник времени (сервер БД) и его формат.
|
||||
### R1. Единый формат — RFC 3339, UTC, суффикс `Z`
|
||||
|
||||
## Длительность — не метка времени
|
||||
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` —
|
||||
одинаково в хранении, логах, API и обмене с внешними системами.
|
||||
|
||||
Измерение длительности операции — отдельная величина: число (обычно
|
||||
миллисекунды) в поле вида `duration_ms`, а не разность двух меток и не
|
||||
время в формате выше. Засекает её тот слой, который делает вызов.
|
||||
**Почему.** Разные форматы в разных слоях требуют преобразования на каждой
|
||||
границе, а ошибка в таком преобразовании не видна сразу: она всплывает через
|
||||
полгода, на переходе на летнее время, когда реальное смещение перестаёт
|
||||
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
|
||||
убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
|
||||
|
||||
**Интервал измеряется монотонными часами процесса**, а не вычитанием
|
||||
сохранённых меток: стенные часы подводит NTP, они могут шагнуть назад и
|
||||
дать отрицательную длительность. Из этого следует, что источник меток
|
||||
времени и источник интервалов — разные, даже если оба называются «часы».
|
||||
### R2. Ширина строки фиксируется на каждый носитель
|
||||
|
||||
## Зоны
|
||||
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
|
||||
строки времени одна и от записи к записи не плавает.
|
||||
|
||||
Единственное место, где появляется не-UTC, — **отображение пользователю**.
|
||||
Зона берётся из конфигурации (`arch/config.md`), значение по умолчанию —
|
||||
`UTC`. На хранение, сортировку и логи она не влияет.
|
||||
**Почему.** Лексикографическая сортировка совпадает с хронологией только
|
||||
среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
|
||||
произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по
|
||||
текстовому полю обязан давать порядок событий. Плавающая ширина (типичный
|
||||
источник — форматирование, отбрасывающее незначащие нули) ломает порядок не
|
||||
везде, а только на тех парах записей, где дробная часть оказалась короче, —
|
||||
то есть редко, выборочно и невоспроизводимо.
|
||||
|
||||
Если бизнес-логика оперирует календарными сущностями («сегодня»,
|
||||
«за месяц»), зона указывается **явно** в месте вычисления — молчаливое
|
||||
использование системной зоны процесса запрещено: она разная на ноутбуке и в
|
||||
контейнере. По умолчанию это та же зона, что и для отображения; если
|
||||
календарная логика требует другой, это записывается явно.
|
||||
### R3. Точность разных носителей может различаться
|
||||
|
||||
Конвенция описывает фиксацию **свершившихся моментов**. Планирование
|
||||
будущих событий — отдельный случай (там хранят локальное время плюс имя
|
||||
зоны, потому что правила зон меняются); пока такой сущности нет, правило не
|
||||
формулируем.
|
||||
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
|
||||
|
||||
**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что
|
||||
строки разных носителей между собой не сравниваются: сортировка идёт внутри
|
||||
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался
|
||||
как «одна точность на всё приложение»: от подгонки формата логов под формат
|
||||
колонки ни одна пара строк не становится сравнимой, зато точность режется до
|
||||
худшего из носителей.
|
||||
|
||||
### R4. Локальное время не хранится и не передаётся
|
||||
|
||||
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
|
||||
зоне.
|
||||
|
||||
**Почему.** Метка без зоны неинтерпретируема вне процесса, который её
|
||||
записал: чтобы понять, какому моменту она соответствует, читателю нужно
|
||||
знать настройки чужой машины на момент записи. И даже зная их, он не
|
||||
разберёт час перехода на зимнее время: этот час идёт дважды, две записи
|
||||
получают одинаковую метку, и порядок между ними не восстанавливается ничем.
|
||||
|
||||
### R5. Единая точка получения «сейчас», форматирования и разбора
|
||||
|
||||
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
|
||||
метки; прямые вызовы часов по коду не разбросаны.
|
||||
|
||||
**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех
|
||||
меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
|
||||
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
|
||||
данных, и обнаруживается, когда испорченных записей уже накопилось.
|
||||
Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`).
|
||||
|
||||
### R6. Дефолтов времени в схеме БД нет
|
||||
|
||||
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
|
||||
|
||||
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий
|
||||
код: значение появляется, но приходит от сервера БД — то есть с других часов
|
||||
и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка
|
||||
падает громко и чинится в момент написания, а не при разборе расхождения
|
||||
между временем в записи и временем в логе. Правило то же, что для
|
||||
идентификаторов (`arch/db-identifiers.md R2`).
|
||||
|
||||
### R7. Длительность — отдельная величина, а не пара меток
|
||||
|
||||
**ДОЛЖЕН.** Длительность операции записывается числом (обычно
|
||||
миллисекундами) в поле вида `duration_ms`.
|
||||
|
||||
**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько».
|
||||
Пара меток заставляет каждого потребителя знать, какие именно две из них
|
||||
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
|
||||
логе; число сравнивается, агрегируется и попадает в перцентили без этого
|
||||
шага. Кроме того, разность сохранённых меток считается по стенным часам и
|
||||
наследует их дефект (R9).
|
||||
|
||||
### R8. Длительность засекает слой, который делает вызов
|
||||
|
||||
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
|
||||
|
||||
**Почему.** Обе границы операции видит только этот слой: замер уровнем выше
|
||||
приписывает операции чужие накладные расходы, уровнем ниже — теряет часть
|
||||
вызова. В обоих случаях число остаётся правдоподобным и потому не
|
||||
оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
|
||||
|
||||
### R9. Момент и интервал берутся с разных часов
|
||||
|
||||
**ДОЛЖЕН.** Источник зависит от того, что записывается:
|
||||
|
||||
| № | Величина | Источник |
|
||||
|---|---|---|
|
||||
| R9.1 | момент события | стенные часы через единую точку (R5) |
|
||||
| R9.2 | длительность операции | монотонные часы процесса |
|
||||
|
||||
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
|
||||
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
|
||||
правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для
|
||||
меток: их ноль произволен и не переживает перезапуск процесса, так что вне
|
||||
процесса такое значение ничего не означает. Отсюда следствие, которое легко
|
||||
упустить: источник меток времени и источник интервалов — разные, даже если
|
||||
оба называются «часы».
|
||||
|
||||
### R10. Не-UTC существует только на слое отображения
|
||||
|
||||
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
|
||||
проникает в хранение, сортировку и логи.
|
||||
|
||||
**Почему.** Как только конвертация уходит вглубь, результат вычислений
|
||||
начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт
|
||||
разные группировки, а порядок записей перестаёт быть общим для всех. Ещё
|
||||
хуже, что при конвертации в нескольких слоях её легко выполнить дважды —
|
||||
смещение удваивается, результат остаётся похожим на правду, а найти
|
||||
виновный слой можно только перечитав их все.
|
||||
|
||||
### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
|
||||
|
||||
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение
|
||||
по умолчанию — `UTC`.
|
||||
|
||||
**Почему.** Зашитая в код зона превращает переезд или второго пользователя в
|
||||
другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано
|
||||
потому, что оно не притворяется настроенным: показанное время совпадает с
|
||||
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
|
||||
как «зону не задали», а не как «где-то потерялось смещение».
|
||||
|
||||
### R12. В календарных вычислениях зона указывается явно
|
||||
|
||||
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
|
||||
явно переданной зоной, а не с системной зоной процесса.
|
||||
|
||||
**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на
|
||||
сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта:
|
||||
расхождение не воспроизводится там, где его заметили, и объясняется средой,
|
||||
а не кодом. Явно переданная зона делает результат функцией от аргументов.
|
||||
|
||||
Зона по умолчанию здесь та же, что и для отображения (R11); календарная
|
||||
логика, которой нужна другая, получает её тем же явным аргументом.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
Reference in New Issue
Block a user