# gitaspen docs

Раскладка кода по слоям, разворачивание и эксплуатация, правила работы и дизайн интерфейса — то, что одинаково для всех проектов.

Полный текст базы одним файлом. Отдельные документы и их исходники — http://docs.gitaspen.ru/llms.txt

---

Документ: http://docs.gitaspen.ru/ROUTES

# Маршруты чтения

Что читать под конкретную задачу и в каком порядке. Каждый маршрут собран так, чтобы после него
задачу можно было выполнить целиком, не догадываясь о пропущенном.

Общее для всех маршрутов: [PRINCIPLES](./development/architecture/PRINCIPLES.md) — почему всё устроено именно
так. Одна страница, читается первой и объясняет решения в остальных документах.

---

## Собрать бэкенд

| № | Документ | Что даёт |
|---|---|---|
| 1 | [PRINCIPLES](./development/architecture/PRINCIPLES.md) | принципы: контракт на границе, ничто не приколочено |
| 2 | [BMAP](./development/architecture/BMAP.md) | корни репозитория, место бэка в продукте |
| 3 | [BMBP](./development/architecture/BMBP.md) | слои, API-иерархия, конверт, данные, идемпотентность, транзакции |
| 4 | [AUTH](./development/architecture/AUTH.md) | кто обращается и что ему можно: удостоверения, правила доступа |
| 5 | [Стиль кода бэка](./development/process/backend-code-style.md) | именование, типизация, исключения, асинхронность, журнал |
| 6 | [База данных](./development/operations/database.md) | схема на сервис, миграции, подключение и таймауты |
| 7 | [Тестирование](./development/process/testing.md) | что покрывать на каждом слое |
| 8 | [Git и репозитории](./development/process/git-and-repositories.md) | что попадает в историю, как ведём версии |

Если бэков несколько и перед ними нужен единый вход — дальше [BMGP](./development/architecture/BMGP.md).
Если сервисы общаются событиями — [Обмен сообщениями](./development/operations/message-queues.md).

---

## Собрать фронт

| № | Документ | Что даёт |
|---|---|---|
| 1 | [PRINCIPLES](./development/architecture/PRINCIPLES.md) | принципы |
| 2 | [BMAP](./development/architecture/BMAP.md) | место фронта в продукте, связь с бэком |
| 3 | [BMFP](./development/architecture/BMFP.md) | слои, состояние, контракт, маршруты, стили, оптимизация загрузки |
| 4 | [Правила дизайна](./development/design/DESIGN_RULES.md) + [Адаптивность](./development/design/RESPONSIVE.md) | как это должно выглядеть и вести себя на разных экранах |
| 5 | [Стиль кода фронта](./development/process/frontend-code-style.md) | именование, вложенность стилей, приёмы разметки |
| 6 | [Тестирование](./development/process/testing.md) | что покрывать на каждом слое |
| 7 | [Git и репозитории](./development/process/git-and-repositories.md) | версии и история |

Если во фронте есть вход и закрытые разделы — [AUTH](./development/architecture/AUTH.md): что хранится в
браузере, когда продлевается сессия, почему право проверяется на сервере, а не кнопкой.

Форма ответов бэка описана в [BMBP](./development/architecture/BMBP.md) — читается, если контракт ещё не задан.

---

## Собрать шлюз

| № | Документ | Что даёт |
|---|---|---|
| 1 | [BMGP](./development/architecture/BMGP.md) | структура, маршруты, upstream, сквозные правила |
| 2 | [BMBP](./development/architecture/BMBP.md), раздел про API-иерархию | какие ручки бэков существуют и чем отличаются |
| 3 | [Сетевой контур](./development/operations/network-topology.md) | где шлюз стоит в цепочке и что публикует |

---

## Развернуть систему на сервере

Порядок обязателен: каждый следующий документ опирается на состояние, оставленное предыдущим.

| № | Документ | Что даёт |
|---|---|---|
| 1 | [Настройка сервера](./development/operations/server-setup.md) | пользователь, вход по ключу, межсетевой экран, часы, журналы |
| 2 | [Docker](./development/operations/docker-install.md) | движок, правила публикации портов, ротация логов |
| 3 | [Частная сеть](./development/operations/private-network.md) | обращение между машинами по частным адресам |
| 4 | [Сетевой контур](./development/operations/network-topology.md) | как соединены слои, что наружу не смотрит |
| 5 | [База данных](./development/operations/database.md) | хранилище, пользователи, схема на сервис, миграции |
| 6 | [Секреты](./development/operations/secrets.md) | откуда части системы берут пароли и ключи |
| 7 | [Запуск приложения](./development/operations/running-an-application.md) | compose продукта, проверки готовности, ограничения |
| 8 | [HTTPS для домена](./development/operations/tls-certificates.md) | домен, сертификат, автопродление |
| 9 | [Резервное копирование](./development/operations/backup-and-restore.md) | копии и проверка восстановлением — до первого выката |
| 10 | [Релиз и выкат](./development/operations/release-and-deploy.md) | сборка, выкат без простоя, откат |
| 11 | [Наблюдение](./development/operations/observability.md) + [Журналы](./development/operations/logs.md) | метрики, пороги, оповещения, сбор записей |

Если сервисы общаются событиями, между шагами 7 и 8 добавляется
[Обмен сообщениями](./development/operations/message-queues.md): брокер поднимается до потребителей, иначе темы
рождаются с настройками по умолчанию.

Копии настраиваются **до** первого выката с миграциями: выкат, меняющий схему, без свежей копии не
имеет пути назад.

---

## Выпустить новую версию

| № | Документ | Что даёт |
|---|---|---|
| 1 | [Git и репозитории](./development/process/git-and-repositories.md) | чистое зафиксированное состояние, метка версии |
| 2 | [Тестирование](./development/process/testing.md) | прогон как условие выпуска |
| 3 | [Релиз и выкат](./development/operations/release-and-deploy.md) | сборка артефакта, выкат, откат, миграции |
| 4 | [Наблюдение](./development/operations/observability.md) | что смотреть сразу после выката |

---

## Выделить переиспользуемую единицу

| № | Документ | Что даёт |
|---|---|---|
| 1 | [REUSE](./development/architecture/REUSE.md) | когда выделять, топология, подключение с закреплённой версией |
| 2 | [BMFP](./development/architecture/BMFP.md), раздел про модули | как устроен фронт-модуль и его публичный контракт |
| 3 | [Git и репозитории](./development/process/git-and-repositories.md) | версии: что означает каждая часть номера |
| 4 | [Тестирование](./development/process/testing.md) | тесты принадлежат единице, а не потребителю |

---

## Разобраться, почему не работает

| Симптом | Куда смотреть |
|---|---|
| сайт не открывается, ошибка сертификата | [HTTPS для домена](./development/operations/tls-certificates.md), раздел отказов |
| `502` или `504` | [Сетевой контур](./development/operations/network-topology.md), проверка по слоям |
| сервис доступен снаружи в обход шлюза | [Docker](./development/operations/docker-install.md), публикация портов |
| после выката всё сломалось | [Релиз и выкат](./development/operations/release-and-deploy.md), откат |
| данные потеряны | [Резервное копирование](./development/operations/backup-and-restore.md), восстановление |
| непонятно, где искать причину | [Наблюдение](./development/operations/observability.md), разбор отказа |
| нужны записи за прошлый час | [Журналы](./development/operations/logs.md), запросы к хранилищу |
| действие выполнилось дважды | [BMBP](./development/architecture/BMBP.md), идемпотентность |
| сообщения обрабатываются не по порядку | [Обмен сообщениями](./development/operations/message-queues.md), ключ и порядок |
| вход перестал работать, разлогинивает | [AUTH](./development/architecture/AUTH.md), таблица отказов |
| машины не видят друг друга по частным адресам | [Частная сеть](./development/operations/private-network.md), таблица отказов |
| часть не поднимается или не выходит в готовность | [Запуск приложения](./development/operations/running-an-application.md), где искать причину |
| ошибка подключения к базе, исчерпан пул | [База данных](./development/operations/database.md), таблица отказов |
| секрет попал в репозиторий | [Секреты](./development/operations/secrets.md), раздел про утечку |
| заперся снаружи, потерян доступ к серверу | [Настройка сервера](./development/operations/server-setup.md), откат |

---

## Внести изменение в существующий код

| № | Документ | Что даёт |
|---|---|---|
| 1 | [Работа в кодовой базе](./development/process/rules.md) | с чем сверяться, как вносить правку, что делать при расхождении с архитектурой |
| 2 | Спецификация нужного слоя: [BMFP](./development/architecture/BMFP.md), [BMBP](./development/architecture/BMBP.md) или [BMGP](./development/architecture/BMGP.md) | границы, за которые нельзя выходить ради краткости |
| 3 | Стиль соответствующей стороны: [фронт](./development/process/frontend-code-style.md) или [бэк](./development/process/backend-code-style.md) | чтобы правка читалась как соседний код |
| 4 | [Тестирование](./development/process/testing.md) | что прогнать перед сдачей |
| 5 | [Git и репозитории](./development/process/git-and-repositories.md) | одно изменение — один коммит |

---

## Написать документ в библиотеку

| № | Документ | Что даёт |
|---|---|---|
| 1 | [Документ библиотеки](./development/process/writing-a-library-document.md) | критерий готовности, обязательные части |
| 2 | [Правила письма](./development/process/writing-rules.md) | язык: точность, нейтральность, чего избегать |

Перед публикацией — `./tools/check-leaks.sh`.

---

Документ: http://docs.gitaspen.ru/development/architecture/BMAP

# BMAP — Base Multi Application Platform

Архитектура приложения целиком: репозиторий, в котором живут фронты (BMFP) и бэки (BMBP), и
способ их сборки в один продукт. BMAP описывает только корни репозитория и границы между ними;
внутреннее устройство — в [BMFP](./BMFP.md), [BMBP](./BMBP.md), [BMGP](./BMGP.md).

## Структура репозитория

```
<product>/
├── frontend/
│   ├── apps/<app>/             # продуктовые фронты (BMFP)
│   ├── microfrontends/<mfe>/   # микрофронтенды (BMFP), монтируются в shell
│   └── modules/<module>/       # source-first frontend-модули (BMFP/REUSE, опц.)
├── backend/
│   ├── services/<service>/     # бэки (BMBP)
│   └── gateways/<frontend>_gateway/  # шлюзы (BMGP, опц.), по одному на фронт
├── documentation/              # дока продукта
├── tools/                      # автоматизация репозитория
└── tests/                      # репо-уровневые / E2E (юнит-тесты — рядом со своим пакетом)
```

| Корень | Что внутри |
|---|---|
| `frontend/apps/`, `frontend/microfrontends/` | фронты по [BMFP](./BMFP.md) |
| `frontend/modules/` | переиспользуемые source-first frontend-модули по BMFP и [REUSE](./REUSE.md) |
| `backend/services/` | бэки по [BMBP](./BMBP.md); или нативная оболочка, если API не нужен |
| `backend/gateways/` | шлюзы по [BMGP](./BMGP.md) |
| `documentation/`, `tools/`, `tests/` | дока, автоматизация репо, репо-уровневые тесты |

Каждый фронт/бэк/шлюз — самостоятельный пакет со своей обвязкой (контейнер, зависимости, секреты).
BMAP отвечает только за корни и границы; имена файлов внутри пакета — по правилам его архитектуры.

Новый фронт → пакет в `frontend/apps/` (и при необходимости свой шлюз в `backend/gateways/`).
Новый бэк → пакет в `backend/services/`.

`frontend/modules/` — локальные checkout переиспользуемых frontend-единиц. Логически такой модуль
остаётся независимым соседним репозиторием, а не собственностью продукта; продукт лишь закрепляет
его версию. Встроенный модуль собирается вместе с приложением. Независимо поставляемый UI с
собственной точкой монтирования относится к `frontend/microfrontends/`.

## Связь фронта и бэка

Фронт и бэк связаны только контрактом — формой данных, не общим кодом. Контракт — конверт:

- успех: `{ status, data, details? }`
- ошибка: `{ status, error_code, details }`

Поменять реализацию любой стороны можно, не трогая другую, пока конверт не меняется.

Слои фронта и бэка зеркальны по ролям:

| BMFP | BMBP | роль |
|---|---|---|
| `boundary` | `api` | граница с внешним миром |
| `domain` | `core` | бизнес-логика |
| `infrastructure` | `infrastructure` | внешний мир (клиенты, хранилища) |
| `shared` | `shared` | межслойный код |

Клиент в `infrastructure` фронта смотрит ровно в `api` бэка. Если есть шлюз — смотрит в шлюз,
шлюз проксирует в бэки.

Поток запроса:

```
действие пользователя в boundary
  → domain.service (BMFP)
    → infrastructure.client ──конверт──▶ api (BMBP)
                                            → core.solution/service
                                              → infrastructure.repository → БД
                                          ◀── конверт ───
  ◀── DTO в store ── валидация ──┘
boundary перерисовывается
```

## Варианты поставки

- **Десктоп.** Фронт (BMFP) в WebView + бэк как нативная оболочка (окно, IPC-вызовы, доступ к ОС).
  Отдельного API нет; «серверные» заботы свёрнуты в оболочку, BMBP остаётся ментальной картой.
- **Контейнерный стек.** БД → бэк (BMBP) → веб со статикой фронта, прокси отдаёт `/api` на бэк.
- **Несколько бэков + шлюзы.** Несколько BMBP-бэков, шина событий, шлюзы (по одному на каждый
  фронт, агрегируют ручки из нужных бэков), несколько BMFP-фронтов и MFE.

Граница (конверт) одинакова во всех вариантах.

## Нативная оболочка вместо BMBP

Когда отдельного API нет, бэкенд — это нативная оболочка. Понятия BMBP проецируются так:

| BMBP | В оболочке |
|---|---|
| api | IPC-вызовы + события (тонкие хендлеры) |
| core | чистая логика на языке оболочки или `domain` фронта |
| infrastructure | доступ к ОС/ФС за обёртками; на фронте — обёртки IPC |

Когда появляется настоящий BMBP-бэк — он добавляется отдельным пакетом в `backend/services/`.

## Заведение нового продукта

1. Создать корни: `frontend/`, `backend/`, `documentation/`, `tools/`, `tests/`.
2. Согласовать контракт: конверт `{ status, data, details? }` и DTO ключевых сущностей.
3. Поднять бэк по [BMBP](./BMBP.md) (или нативную оболочку, если API не нужен).
4. Поднять фронт по [BMFP](./BMFP.md), клиент `infrastructure` нацелить на бэк (или шлюз).
5. Выбрать упаковку (десктоп / контейнерный стек / несколько бэков + шлюзы), описать в `documentation/`.

Порядок: сначала контракт, потом независимое наполнение частей, потом сборка в поставку.

---

Документ: http://docs.gitaspen.ru/development/architecture/BMBP

# BMBP — Base Multi Backend Platform

Архитектура бэкенда: сервиса, API, монолита или бэкенда для приложения, любого размера и для
любого транспорта (синхронные запросы, события, реалтайм).

## Слои

```
api → core → infrastructure → shared
```

| Слой | Что внутри | Может импортировать |
|---|---|---|
| `api` | приём запросов и событий, валидация, маршрутизация, ошибки; тонкий | `core`, `shared` |
| `core` | бизнес-логика: services, solutions; доменная модель (dto, enums) | `infrastructure`, `shared` |
| `infrastructure` | БД (модели, репозитории), внешние клиенты, события, безопасность | `shared` |
| `shared` | преобразования моделей, логирование, базовые настройки | ничего |

Правила:
- `api` тонкий: принять, провалидировать, вызвать `core`, отдать ответ. Бизнес-логики и прямых обращений к репозиториям нет.
- `core` не знает о транспорте.
- `infrastructure` не содержит бизнес-логики и не бросает транспортных ошибок — только доменные.
- `shared` ни от кого не зависит.

## Структура каталога

```
<service>/
├── app/
│   ├── config                       # настройки + имя/схема сервиса
│   ├── entrypoint                   # точка входа: поднять менеджеры транспортов
│   ├── api/
│   │   ├── _shared                  # базовый менеджер транспорта + супервизор
│   │   ├── rest/
│   │   │   ├── manager              # менеджер транспорта (startup/run/shutdown)
│   │   │   ├── app-assembly         # сборка приложения транспорта
│   │   │   ├── routers/
│   │   │   │   ├── base-router      # корневой агрегатор
│   │   │   │   ├── external/
│   │   │   │   │   ├── front/<domain>/   # для фронтендов
│   │   │   │   │   └── back/<domain>/    # для других сервисов
│   │   │   │   └── internal/<domain>/    # отладка/админ, мимо шлюзов
│   │   │   └── _shared/             # фабрика эндпойнтов, декораторы, middleware
│   │   └── events/                  # (опц.) события: manager · handlers/<topic>/
│   ├── core/
│   │   ├── domains/
│   │   │   ├── dtos/<domain>        # DTO — формы данных
│   │   │   ├── enums/<x>            # доменные перечисления
│   │   │   └── mappings             # маппинги, константы (опц.)
│   │   ├── services/<domain>/       # service · dependencies — одна доменная зона
│   │   ├── solutions/<domain>/      # solution · dependencies — оркестрация сервисов
│   │   └── exceptions               # доменные исключения
│   ├── infrastructure/
│   │   ├── db/
│   │   │   ├── models               # модели хранения (общая база со схемой и таймстампами)
│   │   │   ├── repositories/<domain>/   # CRUD · dependencies
│   │   │   ├── connection           # подключение, пул
│   │   │   └── lifecycle            # инициализация схемы/таблиц, закрытие
│   │   ├── clients/<service>/       # клиенты соседних сервисов · dependencies
│   │   ├── event-clients/<topic>/   # (опц.) исходящие события
│   │   └── security                 # токены, подписи, лимиты
│   └── shared/
│       ├── mapper                   # DTO ↔ модель хранения ↔ схема API
│       ├── logging
│       └── settings-base            # базы схем запрос/ответ
├── migrations/                      # миграции БД (вне app/)
├── tests/
├── <манифест-зависимостей>
├── container                        # образ
├── compose                          # сервис + БД
└── .secrets/                        # переменные окружения, ключи (вне VCS)
```

Каждый домен (`<domain>`) — папка с фиксированным набором ролей: handlers, schemas, dependencies в
`api`; service, dependencies в `core/services`; repo, dependencies в `infrastructure/db/repositories`.

## Что в каждом слое

### api

Транспорт и приём запросов и событий: маршруты, обработчики, middleware, декораторы, модели
ошибок. Делегирует в `core`.

Маршруты делятся по адресату, а не по продукту. Сервис не знает про конкретные фронты — их
агрегируют шлюзы:

| Группа | Префикс | Для кого |
|---|---|---|
| `external/front` | `/api/front` | фронтенды |
| `external/back` | `/api/back` | другие сервисы |
| `internal` | `/internal` | отладка/админ, мимо шлюзов |

Разграничение доступа — проверкой роли в эндпойнте, не группой роутеров.

### core

- **domains** — `dtos`, `enums`, `mappings`. Доменная модель.
- **services** — одна доменная зона. Работают с DTO и репозиториями. Сервисы разных зон не общаются
  напрямую — только через solutions.
- **solutions** — оркестрируют несколько сервисов в сценарий, используют внешних клиентов и
  события. К репозиториям напрямую не ходят.
- **exceptions** — доменные исключения.

### infrastructure

- **db** — общая база моделей задаёт схему и таймстампы; модели, репозитории (CRUD), подключение,
  инициализация. Репозитории используются `core`-сервисами напрямую.
- **clients** — клиенты соседних сервисов.
- **event-clients** — исходящие события.
- **security** — токены, подписи, лимиты.

Логики тут только для оптимизации запросов. Транспортных ошибок не бросает.

### shared

Преобразования моделей (mapper), логирование, базы схем. Ни от кого не зависит.

## API: иерархия маршрутов

Дерево из агрегаторов: домен экспортирует набор маршрутов → уровень-агрегатор подключает домены под
своим префиксом → корневой агрегатор собирает уровни.

```
корень
├── /api  → external
│   ├── /api/front → front → /api/front/<domain>
│   └── /api/back  → back  → /api/back/<domain>
└── /internal → internal → /internal/<domain>
```

Модуль домена: `handlers` (обработчики), `schemas` (запрос/ответ + примеры), `dependencies` (сборка
зависимостей), опционально `errors`.

## API: фабрика эндпойнтов

Эндпойнты объявляются единой обёрткой, которая делает то, что иначе дублировалось бы в каждом
обработчике:

1. **Конверт ответа.** Оборачивает результат в `{ status, data, details? }`. Обработчик возвращает
   чистые данные.
2. **Каталог ошибок.** Стандартные коды (валидация, доступ, конфликт, лимит, внутренняя) с
   примерами подключаются автоматически; кастомные — мержатся поверх.
3. **Декларация форм ответа** для авто-документации.

Поверх — общая обёртка ошибок: ожидаемые (доменные) поднимаются типовыми «подъёмниками» с машинным
`error_code`; неожиданные перехватываются, логируются со стеком, наружу отдаются обезличенным
кодом.

## API: схемы и DI

**Схемы.** Базовые `RequestSchema` / `ResponseSchema` из `shared`. Примеры — рядом со схемой.
Типизация — доменными enum. Бизнес-логики в схемах нет.

**DI.** Без глобального контейнера, явная композиция. Две роли имён:
- `get_*` — в API-слое (точка входа DI обработчика);
- `build_*` — в `core` и `infrastructure`.

Каскад: `get_<domain>_solution` → `build_<domain>_solution` → `build_<domain>_service` →
`build_<domain>_repo`.

## API: аутентификация и ID

Доступ объявляется требованием на входе: «нужен валидный пользователь» или «нужна роль X». Механизм
проверяет токен, при провале — стандартный отказ, в обработчик передаёт уже проверенного
пользователя. Авторизация единая для всех клиентов; шлюз пробрасывает токен, проверяет сам сервис.

ID ресурса: действие над конкретным ресурсом — в пути; массовые операции и фильтрация — в теле.

## Три модели данных

| Модель | Где | Роль |
|---|---|---|
| DTO | `core/domains/dtos` | обмен между слоями (core ↔ infrastructure) |
| Модель хранения | `infrastructure/db/models` | таблицы/документы в БД |
| Схема API | `api/.../schemas` | запрос/ответ на границе |

Преобразования — через `shared/mapper`. Наружу всё уходит в конверте `{ status, data, details? }` —
это контракт с [BMFP](./BMFP.md).

## Жизненный цикл

Каждый транспорт (REST, события, реалтайм) — менеджер с единым контрактом: `startup` (подготовка
ресурсов) → `run` (долгоживущая работа) → `shutdown` (корректное завершение с таймаутом). Над ними
супервизор: поднимает все менеджеры, гасит в обратном порядке по сигналу. Добавить транспорт =
добавить ещё один менеджер в список.

Точка входа:

```
старт:
  настроить логирование
  инициализировать БД (схема + таблицы)
  [запустить фоновые задачи, если есть]
  собрать список менеджеров
  отдать супервизору
стоп (всегда):
  закрыть БД
```

Минимум для запуска с нуля — сервис + БД в `compose`. Всё вокруг (приватная сеть, прокси, брокер
событий) — обвязка развёртывания, не часть архитектуры.

Фоновые задачи (если есть) кладут рядом с сервисом домена и запускают после инициализации БД; они
идемпотентны и корректно завершаются при остановке.

## Изоляция по схемам БД

Каждый сервис получает свою схему БД, имя выводится из имени сервиса. Общая база моделей задаёт
эту схему один раз; все таблицы создаются в ней. Это снимает конфликты имён, когда несколько
сервисов делят один кластер БД, и даёт безопасный сброс (удаление схемы целиком).

## Межсервисное взаимодействие

- **Клиенты** (`infrastructure/clients`) — синхронный запрос-ответ к соседям; адреса в настройках.
- **События** — асинхронная связь (pub/sub): исходящие через event-клиент, входящие —
  обработчиками в `api/events`.
- **Шлюзы** — реверс-прокси под конкретный фронт, собирают ручки из нужных бэков. Подробно —
  [BMGP](./BMGP.md).

## Именование

| Что | Правило |
|---|---|
| Домен = папка | набор файлов с фиксированными ролями: `routers/.../<domain>/{handlers, schemas, dependencies}` |
| Агрегатор маршрутов | базовый агрегатор на каждом уровне: `routers/base-router`, `external/front/base-router` |
| Классы слоёв | `XxxService`, `XxxSolution`, `XxxRepo`, `XxxModel` |
| Схемы | `<Verb><Noun>Request`, `<Noun>Response`, `...List` |
| Менеджер транспорта | `XxxManager` |
| Фабрики DI | `get_*` в api, `build_*` в core и infrastructure |

## Идемпотентность

Повторный вызов операции с тем же ключом даёт тот же результат и не создаёт вторую сущность.
Требование обязательно там, где повтор дорого стоит: оплата, создание заказа, списание, отправка
сообщения.

Повтор — не редкость, а норма распределённой системы: клиент нажал дважды, сеть оборвалась после
выполнения, но до ответа, очередь доставила сообщение повторно.

Как обеспечивается:

- **ключ идемпотентности** приходит от вызывающей стороны и хранится вместе с результатом. Повтор с
  тем же ключом возвращает сохранённый результат, а не выполняет операцию заново;
- **проверка текущего состояния** перед изменением: переход выполняется, только если сущность
  находится в ожидаемом состоянии;
- **ключ сообщения** в очереди — потребитель отбрасывает повтор.

## Таймауты и повторы

У каждого внешнего вызова — база, кеш, очередь, чужой API — задан таймаут. Вызов без таймаута
занимает поток до бесконечности: отказ соседа превращается в отказ этого сервиса.

Таймауты задаются в `infrastructure`, рядом с подключением, а не в прикладном коде.

Повторы применяются только к операциям, которые безопасно выполнять дважды — то есть к
идемпотентным. Между попытками пауза растёт (экспоненциальная задержка), число попыток ограничено:
бесконечные повторы превращают кратковременный сбой соседа в лавину запросов.

## Транзакции

Границей транзакции владеет тот слой, который знает бизнес-операцию целиком, — сервис или
сценарий, а не репозиторий. Репозиторий выполняет отдельные запросы и не решает, где фиксировать.

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

## Версионирование API

Версионируется только внешний API — тот, которым пользуются другие. Внутренние ручки версии не
требуют: их потребитель обновляется вместе с сервисом.

Новая версия добавляется рядом со старой, старая продолжает работать до тех пор, пока не отключены
все её потребители. Ломающее изменение внутри текущей версии недопустимо.

## Фоновые задачи

Долгая работа не выполняется внутри обработки запроса: он должен ответить сразу. Задача ставится в
очередь или запускается фоном, а клиент получает идентификатор и отслеживает состояние отдельной
ручкой.

Фоновая задача обязана быть готовой к повторному выполнению: процесс может быть остановлен
посередине.

## Инварианты

1. `api` не ходит в БД и к клиентам — только в `core` через DI (включая `internal`).
2. `core` не знает о транспорте (нет импортов транспортного фреймворка).
3. Сервисы одной зоны не зовут другую напрямую — межзональное только через solutions.
4. `shared` ни от кого не зависит.
5. `infrastructure` не бросает транспортных ошибок; из вышележащего знает только DTO и доменные ошибки.
6. Всё наружу — в конверте (`success` / `error`), голых моделей не возвращаем.
7. Ошибки — через общий канал, не транспортными исключениями по месту.
8. Доступ — проверкой роли, не группами роутеров.
9. DI-нейминг: `get_*` в api, `build_*` в core и infrastructure.

## Добавление домена

1. Модель хранения + миграция.
2. Репозиторий в `infrastructure/db/repositories/<domain>` + `build_<domain>_repo`.
3. DTO и enum в `core/domains`.
4. Сервис в `core/services/<domain>` + `build_<domain>_service`.
5. Solution в `core/solutions/<domain>` — если сценарий многосервисный.
6. API в `api/.../routers/external/front/<domain>` (или back/internal): handlers, schemas,
   dependencies; подключить в агрегатор.
7. Mapper — при необходимости.

## Наш стек

Стек реализации, на которой эта архитектура обкатана:

| Роль | Выбор |
|---|---|
| Язык | Python (async) |
| REST | FastAPI + Uvicorn |
| События | Kafka (через свою обёртку над клиентом) |
| БД + ORM | PostgreSQL + SQLAlchemy 2 (async) + asyncpg |
| Миграции | Alembic |
| Схемы и настройки | Pydantic 2 + pydantic-settings |
| Межсервисный HTTP | httpx |
| Кеш | Redis |
| Авторизация, крипто | PyJWT, cryptography |
| Загрузки | python-multipart |

---

Документ: http://docs.gitaspen.ru/development/architecture/BMFP

# BMFP — Base Multi Front Platform

Архитектура фронтенда: сайта, веб-приложения, UI десктоп-приложения, мини-аппа. Одна и та же
раскладка слоёв работает для любого UI независимо от того, есть ли за ним бэкенд.

## Слои

```
boundary → domain → infrastructure → shared
```

| Слой | Что внутри | Может импортировать |
|---|---|---|
| `arch` | роутинг, инициализация, точка входа | все слои (только для bootstrap) |
| `boundary` | страницы, компоненты, виджеты, лэйауты, UI-хуки | `domain`, `infrastructure`, `shared` |
| `domain` | services, states, dtos, enums | `infrastructure`, `shared` |
| `infrastructure` | клиенты протоколов, хранилища (stores) | `shared` |
| `shared` | утилиты, конфиги, интеграции, общие типы | ничего |

Правила:
- `boundary` не вызывает клиенты напрямую и не дёргает stores — только через хуки `_hooks/`. За
  данными ходит в `domain`-сервисы.
- `domain` содержит всю бизнес-логику, не зависит от UI.
- `infrastructure` — адаптеры (протоколы, хранение), без бизнес-логики.
- `shared` ни от кого не зависит.

## Структура каталога

```
app/
├── arch/                        # точка входа: роутинг + инициализация
│   ├── entry                    # bootstrap: монтирует приложение в роутер
│   ├── mfe-entry                # точка монтирования для микрофронтенда
│   ├── routers/                 # маршруты, guards
│   └── boot/                    # сплэш, предзагрузка
├── boundary/
│   ├── pages/<feature>/
│   ├── components/
│   ├── widgets/
│   ├── layouts/
│   ├── _hooks/                  # UI-хуки: подписка на хранилища
│   └── __styles/
├── domain/
│   ├── services/<domain>        # оркестраторы: клиент + хранилище
│   ├── states/<domain>          # доменные состояния (формы, корзины, расчёты)
│   ├── dtos/<domain>            # схемы валидации данных
│   └── enums/
├── infrastructure/
│   ├── clients/
│   │   ├── http/                # базовый клиент + клиенты на API + хранение токена
│   │   └── ws, sse, …           # прочие протоколы по мере надобности
│   └── stores/<domain>          # хранилища + базовое хранилище
└── shared/
    ├── utils/                   # ошибки, форматирование, общие хуки
    ├── configs/                 # URL, константы
    ├── integrations/            # обёртки внешних SDK/платформ
    └── types/
```

Алиасы импортов `@arch`, `@boundary`, `@domain`, `@infrastructure`, `@shared` в конфиге сборки и
типов закрепляют границы слоёв.

Пакет фронта целиком, поверх `app/`:

```
<front>/
├── app/                # код по слоям выше
├── modules/            # source-first frontend-модули (опц.; отдельные репо/пакеты)
├── public/             # статика
├── <манифест-зависимостей>   # зависимости + скрипты dev/build
├── <конфиг-сборки>     # бандлер + алиасы слоёв
├── <конфиг-типов>
├── container           # образ
└── .secrets/           # переменные окружения (вне VCS)
```

## Frontend-модули

`modules/` — место для крупных переиспользуемых UI-способностей: консоли разработчика, редактора
доски, markdown-движка, просмотрщика файлов. Это не свалка общих компонентов и не копия кода
потребителя. Каждый модуль — самостоятельная source-first единица со своим репозиторием,
манифестом, тестами и публичным контрактом:

```
modules/
└── <module>/
    ├── app/
    │   ├── boundary/           # собственный UI модуля
    │   ├── domain/             # состояния и сценарии модуля
    │   ├── infrastructure/     # stores и стандартные адаптеры
    │   └── shared/             # внутренний фундамент модуля
    ├── public                  # единственная публичная точка импорта
    ├── tests/
    ├── <манифест-зависимостей>
    └── README
```

`arch` модулю не нужен, пока он не запускается самостоятельно: монтирование и маршруты принадлежат
host-приложению. Если модуль получает собственный `mfe-entry`, отдельный бандл и независимый
жизненный цикл поставки — это уже микрофронтенд из `frontend/microfrontends/`, а не встроенный
модуль.

Модуль подключается в host одной композицией через `public`: получает порты/адаптеры, начальные
данные, тему и слоты; наружу отдаёт UI, команды и события. Он не импортирует код host-приложения и
не содержит ветвлений по имени потребителя. Кастомизация делается значениями контракта, а не
форком:

```
host (arch/boot или boundary)
  → module.public({ transport, permissions, theme, slots })
    → внутренние boundary → domain → infrastructure → shared
```

Исходники модуля физически присутствуют в локальном workspace и входят в сборку фронта. Его
репозиторий подключается менеджером пакетов, workspace или git-submodule с закреплённой версией.
После первоначального checkout разработка и сборка работают офлайн. Универсальное исправление
коммитится в репозиторий модуля; продукт затем обновляет закреплённую версию. Постоянные ветки и
копии под каждого потребителя запрещены.

Мелкие компоненты, нужные только текущему фронту, остаются в `app/boundary/components`. Способность
переезжает в `modules/` по правилу [REUSE](./REUSE.md): когда появился второй потребитель либо
собственный независимый жизненный цикл.

## Точка входа

В `arch/entry` приложение монтируется в роутер:

```
root = найти корневой узел разметки
смонтировать(root, Router(Lazy(AppRoot)))
```

`AppRoot` — boot + маршруты из `arch/routers/`. Для микрофронтенда — отдельная точка
`arch/mfe-entry` и отдельная сборка. Выбор платформы и аутентификация — в `boot` или
`domain`-сервисе.

## Что в каждом слое

### boundary

Страницы, компоненты, виджеты, лэйауты, UI-хуки. Не делает сетевых вызовов и не знает форму ответа
сервера. За операциями ходит в `domain`-сервисы. За состоянием — в хранилища, но только через
хуки `_hooks/`, которые подписываются на снимок состояния.

### domain

- **services** — оркестраторы. Один сервис = одна доменная зона. Вызывают клиент из
  `infrastructure`, валидируют, кладут результат в хранилище. Не трогают сетевой примитив, зовут
  клиент.
- **states** — доменные состояния. Сложные объекты с бизнес-логикой (форма заказа, корзина,
  редактирование): считают, валидируют, освобождают ресурсы.
- **dtos** — схемы валидации данных. Источник истины о форме данных, типы выводятся из схем.
- **enums** — доменные перечисления (статусы, типы), не UI-подписи.

### infrastructure

- **clients** — клиенты протоколов. Базовый клиент: подстановка токена, реакция на «разлогинило»,
  разбор конверта, валидация ответа. От него — клиенты конкретных API. Не содержат бизнес-логики.
- **stores** — хранилища. Отвечают на «где и как хранить» (память / локальное хранилище / offline).
  Отдают компонентам реактивный снимок, мутации атомарны. Только операции хранения.

### shared

Утилиты, конфиги, интеграции, общие типы. Только то, что нужно нескольким слоям.

## States и Stores

| | states (`domain/states/`) | stores (`infrastructure/stores/`) |
|---|---|---|
| отвечает на | что такое состояние и его логика | где и как хранить данные |
| содержит | бизнес-логику (расчёты, валидация, автопересчёт) | стратегию хранения |
| примеры | состояние формы заказа | хранилище истории с сервера; хранилище экземпляров состояний |

Store управляет жизненным циклом экземпляров State (создаёт, отдаёт, освобождает). Простые данные
с сервера лежат в data-хранилище напрямую; сложное редактируемое состояние — это State, который
держится в Store.

## Контракт с бэкендом

Все ответы бэкенда приходят в конверте:

```
успех:  { status, data, details? }
ошибка: { status, error_code, details }
```

`error_code` — машиночитаемый код (по нему фронт ветвится), `details` — текст для человека.
Конверт описан схемой валидации в `infrastructure/clients/http/`.

Поток данных: компонент через хук вызывает `domain`-сервис → сервис зовёт клиент → клиент бьёт
в бэк, разворачивает конверт, валидирует по схеме → сервис кладёт DTO в хранилище → компонент
перерисовывается.

## Базовый клиент

Над сетевым вызовом стоит один базовый клиент, который снимает с прикладного кода:
- подстановку текущего токена в каждый запрос;
- реакцию на отказ авторизации (чистит сессию, уводит на вход);
- разворачивание конверта — наружу отдаётся только содержимое;
- валидацию ответа по схеме (при расхождении — явный сбой, не «тихо неправильные» данные).

Добавить вызов к новому эндпойнту = описать один метод с адресом и схемой.

Ошибки транспорта проходят через один обработчик и приходят в виде типовой ошибки с `error_code`;
UI показывает их единообразно.

## Микрофронтенды

Микрофронтенд — самостоятельный BMFP-фронт, монтируемый в оболочку (shell). Экспортирует точку
монтирования `mount(el, props)` в условленный глобальный ключ; собирается отдельным бандлом.
Контракт оболочки (доступ к auth и навигации) описан у shell, MFE общается с ним только через
`props`. Внутри MFE — те же четыре слоя, свой роутер и свои клиенты.

## Мультиплатформенность

Платформа выбирается на старте по среде запуска (обычный web с формой входа, встроенный мини-апп
с авто-входом по данным платформы, десктоп-оболочка). Слои не меняются — меняется только способ
аутентификации в `domain`-сервисе.

## Именование

| Что | Правило |
|---|---|
| Файлы по роли | суффикс роли: `board.dto`, `board.service`, `board.client`, `board.store`, `block.enums` |
| Служебное / приватное | префикс `_` / `__`: `_hooks/`, `_base.store`, `__styles/` |
| Класс-синглтон | класс + экспорт единственного экземпляра: `BoardServiceClass` → `BoardService` |
| DTO | `XxxSchema` + выводимый тип `Xxx`; списки `XxxListSchema` |
| Хук | `useXxx` |
| Импорты | алиасы слоёв, всегда абсолютные |

## Маршруты и защита

Маршруты живут в `arch/routers/`. Страница подключается лениво — так стартовый бандл содержит
оболочку и текущий экран, а не все страницы приложения сразу:

```
MapPage = lazy(() => import('@boundary/pages/map/map'))

<Route path="/map" element={<Protected><MapPage/></Protected>} />
```

`Protected` — обёртка из `arch/routers/`: спрашивает авторизацию у `domain`-сервиса и уводит на
вход, если её нет. Проверка прав живёт в одном месте, а не в каждой странице.

## Стили

Стиль лежит рядом с тем, что он оформляет: `component.tsx` + `component.module.scss`. Локальные
имена классов исключают протекание стилей между компонентами.

Глобально — только то, что не принадлежит компоненту: сброс, шрифты, токены темы (цвета, отступы,
радиусы, типографика) в `boundary/__styles/`. Компоненты используют токены, а не собственные
значения цветов и отступов: тема меняется в одном месте.

## Конфигурация и окружение

Значения окружения хранятся вне репозитория (`.secrets/`) и попадают в сборку через конфиг
бандлера. Переменные, попадающие в клиентский бандл, помечаются оговорённым префиксом — он делает
явным, что значение станет публичным.

Секрет в клиентском бандле не защищён ничем: код фронта доступен пользователю целиком. Всё, что
должно оставаться тайной, живёт на бэкенде.

Адреса API и константы — в `shared/configs/`, а не в коде компонентов.

## Оптимизация загрузки

Что даёт основной выигрыш при холодном входе:

- **разделение по маршрутам** — страницы через `lazy()`; иначе стартовый бандл содержит и те
  экраны, которые пользователь не открывал;
- **отдельный чанк для стабильных зависимостей** (фреймворк, роутер) — он не меняется между
  релизами и остаётся в кэше браузера;
- **ленивая загрузка тяжёлых библиотек** (просмотр PDF, редакторы, разбор разметки) — только на
  экране, где они нужны;
- **кэширование статики**: файлы с хэшем в имени — на год как неизменяемые, точка входа —
  без кэша, иначе релиз не доедет до пользователя.

Ленивая загрузка добавляет режим отказа: после релиза имена файлов меняются, и открытая вкладка
может запросить исчезнувший чанк. Обработчик такой ошибки перезагружает страницу один раз —
иначе пользователь видит пустой экран.

## Ошибки и аналитика

Ошибки транспорта приводятся к одному типу в базовом клиенте (см. выше) и обрабатываются
единообразно. Прикладной код не разбирает исключения на месте вызова, а получает результат в
предсказуемой форме — успех либо типовая ошибка с `error_code`.

Аналитика и логирование — обёртки в `shared/utils/`. Компоненты вызывают обёртку, а не SDK
поставщика: смена поставщика не задевает прикладной код.

## Инварианты

Проверяются поиском по импортам:

1. `boundary` не вызывает транспорт: нет сетевых вызовов и нет импортов клиентов из `@infrastructure/clients`.
2. Хранилища — только через хуки из `boundary/_hooks/`.
3. `shared` не зависит ни от `@boundary`, ни от `@domain`, ни от `@infrastructure`.
4. `infrastructure` знает о `domain` только формы данных: импорт `@domain/dtos` допустим, импорт `@domain/services` или `@boundary` — нет.
5. Бизнес-логика — в `domain`, не в клиентах и не в хранилищах.
6. Любой ответ сервера проходит через схему валидации в клиенте; сырые данные в хранилище не попадают.
7. DTO в `domain/dtos` — единственный источник истины о форме данных.

## Добавление фичи

1. Описать DTO в `domain/dtos/<feature>` — форму данных из BMBP.
2. Завести клиент в `infrastructure/clients/http/<feature>.client`; методы валидируют ответ конвертом.
3. При необходимости — хранилище в `infrastructure/stores/<feature>`; для сложного редактируемого состояния — `domain/states/<feature>`.
4. Сервис в `domain/services/<feature>.service` — оркестрация клиент + хранилище.
5. UI в `boundary/pages/<feature>/` + маршрут в `arch/routers/`; компоненты читают через хук, действия зовут сервис.
6. Утилиты — в `shared`, тесты рядом с кодом.

## Наш стек

Стек реализации, на которой эта архитектура обкатана. Не часть архитектуры — ориентир.

Базовый набор (нужен любому фронту):

| Роль | Выбор |
|---|---|
| Язык | TypeScript |
| UI | React |
| Роутинг | react-router-dom |
| Сборка | Vite + plugin-react-swc |
| Стили | Sass (sass-embedded) |

Добавляется по мере надобности:

| Когда нужно | Выбор |
|---|---|
| Реактивное состояние (states, stores) | MobX + mobx-react-lite |
| Запросы к API | axios |
| Валидация ответов API | Zod |
| Формы | react-hook-form + @hookform/resolvers (+ react-imask для масок) |
| Просмотр PDF | react-pdf |

---

Документ: http://docs.gitaspen.ru/development/architecture/BMGP

# BMGP — Base Multi Gateway Platform

Шлюз — реверс-прокси под конкретный фронт. Собирает в одно API ручки из нескольких бэков
([BMBP](./BMBP.md)), нужных этому фронту. Фронт ходит только в свой шлюз и не знает, сколько за ним
бэков и какие они. Состав шлюза определяется со стороны фронта («какие ручки мне нужны»), не со
стороны бэка («как я сгруппирован»). Один фронт — один шлюз.

Слой опциональный: если фронт работает с одним бэком, шлюз не нужен.

## Структура шлюза

Шлюз — папка с конфигурацией прокси и обвязкой запуска. Прикладного кода нет.

```
<frontend>_gateway/
├── compose                          # запуск контейнера прокси
├── proxy/
│   ├── container                    # образ
│   ├── proxy.conf                   # общие настройки: лимиты, кеш, заголовки
│   └── conf.d/
│       ├── api.conf                 # маршруты: публичный URL → внутренний URL бэка
│       ├── upstreams.template       # список бэков с плейсхолдерами адресов
│       └── cors.conf                # CORS-заголовки
├── tools/
│   ├── entrypoint                   # старт: подставить адреса в upstreams → собрать доки → запустить прокси
│   └── generate-openapi             # собрать openapi.json из api.conf
├── docs/
│   ├── openapi.json                 # сгенерированная спека публичного API
│   └── ui/                          # Swagger / Redoc / Scalar — раздаётся на /docs
├── .secrets/                        # переменные окружения (адреса бэков), вне VCS
└── README
```

Три файла, которые правят при работе:
- **`api.conf`** — какие публичные URL и куда проксируются;
- **`upstreams.template`** — список бэков;
- **`proxy.conf`** — общие настройки.

## Связь файлов в рантайме

Наружу опубликован только шлюз — один публичный порт. Бэки шлюз зовёт по внутренним адресам из
`upstreams`. Какой именно бэк — определяется маршрутом.

```
фронт → шлюз :<порт> → один из бэков (BMBP) → api → core → … → БД
```

Порядок старта (`entrypoint`):
1. Подставить адреса бэков из `.env` в `upstreams.template` → `upstreams.conf`.
2. Сгенерировать `openapi.json` из `api.conf`.
3. Запустить прокси.

Как именно сервисы изолированы от прямого доступа (приватная сеть, файрвол, внутренний bind) —
обвязка развёртывания, не часть архитектуры. Архитектурно важно одно: фронт ходит через шлюз,
сервисы наружу не опубликованы.

## Маршруты

Каждый публичный маршрут = один блок в `api.conf`, проксирующий на внутренний путь операции одного
из бэков (из `external/front` или `external/back` BMBP). Разные маршруты идут в разные бэки — это
и есть агрегация:

```
/api/orders/{id}    →  orders_service /api/front/orders/{id}
/api/users/me       →  auth_service   /users/me
/api/files/upload   →  files_service  /files/upload
```

Адреса `orders_service`, `auth_service`, `files_service` — из `upstreams.conf`, значения — из
переменных окружения.

Для фронта это одно цельное API под одним адресом — без понимания, что за каждой ручкой стоит свой
бэк. Публичные пути не повторяют внутренние; снаружи не видно ни состава бэков, ни их группировки.
Что не описано маршрутом — наружу не идёт (хвостовой `location /` отвечает `404`).

## Два смысла слова «шлюз»

Слово применяется к двум разным вещам, и их различают суффиксом в имени:

| Что это | Имя | Роль |
|---|---|---|
| реверс-прокси перед бэками (этот документ) | `<продукт>_api_gateway` | собирает API для одного фронта |
| самостоятельный фронт-продукт | `<продукт>_gateway` | сайт или приложение, с которым работает человек |

Правило простое: `_api_` в имени означает прокси без прикладного кода. Фронт-шлюз обращается к
своему API-шлюзу, а не к бэкам напрямую.

## Настройка upstream

У каждого бэка — свой блок `upstream`. Кроме адреса задаются параметры устойчивости и
переиспользования соединений:

```nginx
upstream orders_service {
    server ${ORDERS_SERVICE_ADDRESS} max_fails=3 fail_timeout=30s;
    keepalive 32;
}
```

- `max_fails` и `fail_timeout` — после скольких неудач подряд и на какой срок бэк считается
  недоступным. Без них каждый запрос продолжает ждать таймаута упавшего сервиса.
- `keepalive` — сколько соединений держать открытыми. Размер выбирается по нагрузке: для частых
  коротких запросов больше, для редких тяжёлых меньше. Ноль означает новое TCP-соединение (и новое
  рукопожатие) на каждый запрос.

Адрес берётся из переменной окружения, а не пишется в конфигурацию: перенос бэка на другой сервер
меняет только окружение шлюза.

Служебная ручка `/health` отвечает на самом шлюзе, без обращения к бэкам — по ней проверяется, что
жив он сам, а не вся система.

## Сквозное

Задаётся один раз в `proxy.conf` / `cors.conf`, применяется к маршрутам:

- **Лимиты запросов** — зонами под тип нагрузки: строже для входа/auth, мягче для read-only.
- **CORS** — целиком на шлюзе, приложение про CORS не знает.
- **Кеш** read-only — короткий, ключ с учётом `Authorization`.
- **Авторизация** — шлюз пробрасывает `Authorization` без изменений; токен проверяет сам сервис.

## Инварианты

1. Фронт знает только адрес своего шлюза, не адреса бэков.
2. Состав шлюза определяется со стороны фронта; бэки про шлюзы не знают.
3. Один фронт = один шлюз; новый фронт — новая папка, существующие не трогаем.
4. Каждый внешний маршрут объявлен явно; неизвестный путь — `404`.
5. Шлюз несёт только срез операций своего фронта.
6. Лимиты, CORS, кеш — на шлюзе, не в сервисе.
7. Шлюз маршрутизирует, не переформатирует ответы — конверт `{ status, data }` не меняется.

## Добавление шлюза

Начинаем со стороны фронта: какие операции ему нужны и из каких бэков они приходят.

1. Скопировать папку существующего шлюза как `<frontend>_gateway/`.
2. В `upstreams.template` перечислить все бэки, из которых фронт берёт ручки.
3. В `api.conf` на каждую нужную фронту операцию завести маршрут: публичный URL → внутренний путь
   соответствующего бэка; подключить зону лимита, при чтении — кеш.
4. В `.secrets/` заполнить адреса бэков.
5. В `compose` указать порт и среду.
6. Поднять; `openapi.json` соберётся на старте, доки доступны на `/docs`.

## Наш стек

nginx, Docker, docker-compose. `envsubst` подставляет адреса бэков в `upstreams.template`; скрипт
на Python собирает `openapi.json` из `api.conf`. На другом прокси роли те же — карта маршрутов,
список адресов, общие настройки.

---

Документ: http://docs.gitaspen.ru/development/architecture/REUSE

# REUSE — конфлюэнтность и переиспользование

Один закон, работающий на всех уровнях гранулярности: то же, что делает хорошим коммит, делает
хорошим модуль, компонент, движок и продукт. BM ([BMFP](./BMFP.md) / [BMBP](./BMBP.md) /
[BMAP](./BMAP.md)) описывает один продукт изнутри. Этот документ — как единицы переиспользуются
между продуктами и как это удерживается в git.

## Конфлюэнтная единица

Единица любого размера конфлюэнтна, если держит четыре свойства одновременно:

| Свойство | Что значит |
|---|---|
| **Цельность** | одна ответственность; ничего лишнего, ничего недостающего |
| **Самодостаточность** | имеет свою границу (контракт), собирается и проверяется сама, не втягивает соседей внутрь |
| **Неразрушение** | не ломает рабочее состояние соседей и прошлого; добавляется, не подламывая существующее |
| **Однонаправленность** | потребители зависят от неё; она не зависит от того, кто выше неё |

Детектор костыля: если внутри единицы появляется знание о конкретном потребителе
(`if (хост == X)`), свойство самодостаточности нарушено — знание выносится наружу, в потребителя.

## Уровни — одно правило, разная гранулярность

| Уровень | Единица | Контракт (граница) | Где живёт |
|---|---|---|---|
| Коммит | связное изменение | сообщение + рабочее дерево | история репо |
| Модуль / функция | слой `shared` | сигнатуры | внутри пакета (BM) |
| Компонент / виджет | UI-кусок | props / события | пакет, алиас в сборку |
| Движок | host-agnostic ядро | публичный API | отдельный репо |
| Сервис | доменная зона | конверт `{ status, data }` | отдельный бэк ([BMBP](./BMBP.md)) |
| Продукт | сборка единиц | поставка | отдельный репо ([BMAP](./BMAP.md)) |

Четыре свойства из предыдущего раздела одинаковы на каждой строке. Меняется только размер единицы
и форма её контракта. Поэтому правило не нужно учить заново на каждом уровне — оно одно.

## Движок и оболочка

Внутри продукта единица расслаивается по знанию о хосте:

- **Движок** — host-agnostic ядро (чистая логика + рендер), не знает, где исполняется.
- **Оболочка** — тонкая привязка движка к конкретному хосту (ОС, чужая ОС, киоск, сайт).

Переиспользуется движок; оболочка остаётся в продукте. Это та же однонаправленность слоёв из BM,
но на оси «знание о хосте»: движок ← оболочка, не наоборот.

## Жизненный цикл выделения

Корень — расширяемость: модульность нужна, чтобы система росла; переиспользование — её побочный
приз. Выделение не проектируется заранее — оно вызревает:

1. **Рождение в месте нужды.** Способность живёт внутри продукта, который первым её потребовал.
   Выносить раньше — преждевременная абстракция, тот же костыль наоборот.
2. **Перерос место — выделение.** Способность выделяется, когда переросла своё место: либо нужна
   второму потребителю, либо выросла в самостоятельную часть, которой нужен свой пайплайн/жизненный
   цикл. Тогда она переезжает в свою единицу **за границами продукта** — свой репо/папка, своя
   история, своя обвязка (манифест зависимостей, тесты, README), свой контракт.
3. **Потребление по зависимости.** Оба продукта подключают её как зависимость, закреплённую
   версией. Никто не копирует и не форкает.

Триггер выделения — **реальная потребность** (второй потребитель или собственный размер/пайплайн),
не предполагаемая. Не раньше — но и не держим силой, когда единица уже переросла место.

## Топология

- **Продуктовый репо** содержит сборку и оболочки (всё хостовое) и зависит от shared-единиц.
- **Shared-единица — сосед продуктов, а не их ребёнок.** Её канонический репозиторий, история и
  владение находятся вне продукта. Локальный checkout зависимости может быть смонтирован в
  канонический слот продукта (например, `frontend/modules/<module>` BMAP) через workspace или
  submodule, но это не меняет владение и не превращает модуль в дочерний репозиторий продукта.
- **Зависимости между репозиториями однонаправленны**, как слои внутри BM: продукт → shared →
  фундамент. Shared не знает о продукте. Циклов нет.
- Подключение — менеджером пакетов / workspace / submodule, с закреплённой версией. Контракт
  стабилен и версионируется: внутренние изменения единицы не ломают потребителя (semver по смыслу).

## Git

- **Всё под git с первого файла.** Кода вне git нет — ни у продукта, ни у shared-единицы.
- **Local-first.** Git работает полностью локально; удалённый репозиторий — синхронизация и бэкап,
  не условие работы.
- **Граница репо = граница единицы.** Выделили способность — она получает свой репо и свою историю,
  а не размазана по чужой.

Локальная разработка shared-единицы и продукта идёт в одном workspace: весь исходный код уже на
диске, IDE видит обе границы, а сборка не требует сети. Универсальное изменение коммитится в
репозиторий shared-единицы, после чего продукт обновляет закреплённую версию. Специфичное для
продукта знание остаётся в его оболочке. Постоянные ветки и отдельные копии shared-кода под
потребителей не создаются.

## Коммит — наименьшая конфлюэнтная единица

Те же четыре свойства, спроецированные на коммит:

| Свойство | На коммите |
|---|---|
| Цельность | одна подсистема = один коммит; часто и гранулярно |
| Самодостаточность | собирается и проходит проверки сам по себе |
| Неразрушение | дерево рабочее на каждом коммите (история bisectable); прошлое не переписывается деструктивно |
| Однонаправленность | опирается на предыдущие коммиты, не требует будущих |

- Сообщение — сухое, `type(scope): что`. Факт, без пафоса.
- Автор — человек; без авто-атрибуции инструментов.

## Словарь

- **Конфлюэнтность** — свойство единицы быть цельной, самодостаточной, неразрушающей и
  однонаправленно-зависимой; одинаково на всех уровнях.
- **Единица** — носитель конфлюэнтности любого размера: коммит / модуль / компонент / движок /
  сервис / продукт.
- **Движок / оболочка** — host-agnostic ядро и тонкая привязка к хосту; переиспользуется ядро.
- **Выделение (graduation)** — переезд способности из продукта в собственную shared-единицу при
  появлении второго потребителя.
- **Shared-единица** — самодостаточный переиспользуемый пакет за границами продуктов, со своим
  контрактом, историей и обвязкой.
- **Контракт единицы** — её публичная форма (props/события, API, конверт); внутренности скрыты,
  потребитель зависит только от формы.

---

Документ: http://docs.gitaspen.ru/development/architecture/AUTH

# Аутентификация и авторизация

Как система устанавливает, кто обращается, и как решает, что этому обратившемуся разрешено.
Документ задаёт раскладку: какая проверка живёт в каком слое, какие бывают удостоверения и сколько
живёт каждое, как устроен единый вход между несколькими сервисами, как выражается правило доступа,
как выглядит отказ и как убедиться, что код этой раскладке отвечает.

Читатель предполагается знакомым со слоями бэкенда ([BMBP](./BMBP.md)) и с ролью шлюза
([BMGP](./BMGP.md)). Хранение ключа подписи и его плановая замена здесь не описываются: это общее
правило для всех секретов — [секреты](../operations/secrets.md).

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| бэкенд разложен по слоям, доступ объявляется требованием на входе эндпойнта, ответы уходят в конверте (BMBP); наружу опубликован только шлюз, он пробрасывает заголовок авторизации без изменений (BMGP); ключ подписи лежит в `.secrets/` вне репозитория (секреты) | виды удостоверений и сроки их жизни, поток единого входа, место проверки владения объектом, ответы при отказе, проверки соответствия | [тестирование](../process/testing.md): отказ доступа — такой же обязательный случай, как успешный путь; [наблюдение](../operations/observability.md): попытки входа и отказы видны в журнале, значения удостоверений в него не попадают |

Что предыдущий этап обязан обеспечить:

- **единый каталог ошибок** в фабрике эндпойнтов. Без него отказ выражается по-разному в каждом
  обработчике, и клиент не может отличить «продли удостоверение» от «этого нет»;
- **явный перечень маршрутов на шлюзе** (`404` на всё неописанное). Ручка, не попавшая в перечень,
  всё равно существует у сервиса, и её защита — это её собственное требование доступа, а не
  умолчание шлюза;
- **ключ подписи вне репозитория и разный в разных средах.** Общий ключ означает, что удостоверение,
  выпущенное в тестовой среде, действует в боевой.

Что этот документ оставляет следующему: набор удостоверений с известными сроками, один способ
выразить отказ и правило доступа, живущее в `core`. На этом строятся тестовые случаи (отказ
проверяется по коду, а не по тексту) и записи журнала (исход попытки без значения удостоверения).

---

## Два разных вопроса

**Аутентификация** отвечает на вопрос «кто обращается». Результат — идентификатор учётной записи
либо отказ. **Авторизация** отвечает на вопрос «разрешено ли этому обратившемуся вот это действие
над вот этим объектом». Результат — «да» или отказ.

| | Аутентификация | Авторизация |
|---|---|---|
| Вопрос | кто это | что ему можно |
| Вход | удостоверение (токен, код, подпись) | проверенный обратившийся + действие + объект |
| Выход | идентификатор и роль либо отказ | разрешение либо отказ |
| Зависит от | способа входа (браузер, машинный клиент, соседний сервис) | предметной области, не от способа входа |
| Живёт в | `api` (граница) | `core` (бизнес-логика) |

Разделение нужно потому, что стороны меняются независимо. Способов входа со временем становится
больше: форма с паролем, одноразовый код, внешний провайдер, встроенный мини-апп, машинный токен,
внутренний вызов между сервисами. Правило доступа при этом одно и то же: «редактировать заказ может
его владелец или сотрудник поддержки». Если правило записано внутри проверки удостоверения, каждый
новый способ входа требует переписать все правила, а забытая ветка означает не отказ, а
незамеченное разрешение.

Обратная зависимость даёт тот же результат с другой стороны: проверка подписи, знающая про заказы,
не переиспользуется, а копируется в каждый сервис — и копии расходятся.

---

## Где что живёт

| Слой | Что делает | Чего не делает |
|---|---|---|
| шлюз (BMGP) | пробрасывает заголовок авторизации без изменений; вырезает заголовки личности, пришедшие снаружи; держит лимиты частоты на ручки входа; отвечает `404` на неописанные маршруты | не выпускает удостоверений и не решает по существу, пускать ли к объекту |
| `api` | разбирает удостоверение, превращает его в «проверенного обратившегося», при провале отдаёт стандартный отказ; объявляет требование на входе обработчика | не содержит правил доступа к объектам |
| `core` | правило доступа: кто владелец, что можно в текущем состоянии объекта, какой уровень доступа требуется | не разбирает заголовки и не знает о транспорте |
| `infrastructure` | выпуск и проверка подписи, хранение сессий и отпечатков машинных токенов, счётчики попыток, обращение к модулю авторизации | не решает, разрешено ли действие |
| интерфейс (BMFP) | скрывает то, что недоступно, и уводит на вход при отказе | не является местом проверки |

Поток запроса:

```
клиент ──заголовок авторизации──▶ шлюз ──без изменений──▶ api
                                                          │  разбор удостоверения
                                                          │  → проверенный обратившийся
                                                          ▼
                                                        core   ← правило доступа
                                                          │      (роль, владение, состояние)
                                                          ▼
                                                  infrastructure → БД
```

**Проверенный обратившийся** — доменный тип из `core/domains/dtos`, а не транспортная структура:
идентификатор учётной записи, роль, вид удостоверения. `core` принимает его параметром и потому
не зависит от способа входа; в тесте он собирается вручную, без выпуска токена.

**Почему шлюз не проверяет по существу.** Шлюз может выполнять подзапрос к модулю авторизации и
пускать дальше только проверенные запросы. Здесь это не делается по трём причинам:

1. **Шлюз — не единственный вход.** В сервис приходят события из очереди, вызовы соседних сервисов,
   фоновые задачи и внутренние ручки, которые через шлюз не идут (BMBP: группа `internal`).
   Проверка на шлюзе оставляет эти пути без проверки вовсе.
2. **Шлюзов много.** Их по одному на фронт (BMGP). Правило, размещённое на шлюзе, размножается по
   числу фронтов и расходится между копиями.
3. **Шлюз не знает предметной области.** Он может проверить подпись, но не может проверить, что
   заказ принадлежит обратившемуся. Вторая проверка в сервисе всё равно нужна, а две проверки в
   разных местах расходятся.

**Требование объявляется на каждом обработчике, а не группой роутеров.** Группа выглядит удобнее
(«всё под этим префиксом закрыто»), но защита в этом случае — свойство места в дереве маршрутов.
Перенос обработчика в другой роутер снимает её молча: код компилируется, тесты успешного пути
проходят, ручка становится открытой. Требование на обработчике переезжает вместе с ним.

---

## Виды удостоверений

| Вид | Кому выдаётся | Срок (ориентир) | Где хранится | Что делать при утечке |
|---|---|---|---|---|
| **Токен доступа** | человеку — на сеанс работы | 15–30 минут | у клиента (варианты и их риски — ниже), передаётся заголовком; на нашей стороне не хранится, проверяется подписью | отозвать сессию (см. следующую строку); сам токен доступа доживёт до конца срока, поэтому срок и делается коротким. Немедленный отзыв возможен только списком отозванных идентификаторов либо сменой ключа подписи — оба варианта платные, поэтому применяются к инцидентам, а не к обычному выходу |
| **Токен продления** | тому же клиенту | 14–30 дней | у клиента: cookie с признаками `HttpOnly`, `Secure`, `SameSite`; у нас: запись сессии в БД с идентификатором токена | удалить запись сессии — продление перестаёт работать сразу; после истечения текущего токена доступа сеанс закончится |
| **Машинный токен** | скрипту, агенту, интеграции — вместо пароля | месяцы, с обязательной датой окончания | у владельца: вводится один раз в настройках потребителя; у нас: **отпечаток** (`sha-256`), значение не хранится | удалить запись токена; значение восстановить нельзя — владелец выпускает новый |
| **Внутренний токен** | части системы для вызова другой части | до плановой замены | в окружении процесса (секреты); сравнивается в постоянное время | заменить значение по правилам ротации; до замены маршруты, принимающие его, остаются доступными тому, кто значение получил |
| **Одноразовый код обмена** | браузеру на один переход между доменами при едином входе | десятки секунд (ориентир — 60 с) | нигде: во временном хранилище на нашей стороне, удаляется при первом использовании | ничего: код либо уже использован, либо истёк. От перехвата защищает привязка к инициатору (раздел о едином входе) |

Пояснения к таблице:

**Почему токен доступа не хранят в БД.** Смысл подписанного токена в том, что проверка не требует
обращения к хранилищу: любой сервис проверяет его локально. Цена — невозможность мгновенного
отзыва. Поэтому срок жизни короткий, а долгоживущая часть сеанса (токен продления) хранится
записью и отзывается удалением записи.

**Где держать токен доступа в браузере** — выбор с двумя разными рисками, а не один правильный
вариант:

| Место | Риск | Что требуется дополнительно |
|---|---|---|
| память вкладки | теряется при перезагрузке страницы — восстанавливается продлением | ничего |
| постоянное хранилище браузера | читается любым сценарием, исполняемым на странице | политика источников сценариев |
| cookie `HttpOnly` | сценарием не читается, но браузер прикладывает её к запросу автоматически, в том числе к запросу, инициированному чужой страницей | `SameSite` и сверка источника запроса на изменяющих операциях |

Токен продления хранится в cookie `HttpOnly` во всех вариантах: он ценнее токена доступа и
прикладного кода ему не нужен.

**Почему пароль и машинный токен хешируются по-разному.** Пароль придуман человеком, его перебирают
по словарю — нужна функция с настраиваемой стоимостью (`argon2id`, `bcrypt`, `pbkdf2` с большим
числом итераций) и отдельной солью на запись. Машинный токен — случайное значение на 256 бит,
перебирать его нечем, а проверять приходится на каждом запросе; поэтому достаточно быстрого
`sha-256` от значения, и запись ищется прямо по отпечатку. Обратное сочетание даёт либо
подбираемый пароль, либо медленный запрос.

Машинному токену полезен опознаваемый префикс (`svc_…`): по нему точка входа отличает его от
токена сессии, не пытаясь разобрать подпись, а поиск по репозиториям находит утёкшее значение.

---

## Что внутри токена

Минимальный набор полей подписанного токена:

| Поле | Значение | Зачем |
|---|---|---|
| `sub` | идентификатор учётной записи | кто |
| `typ` | `access` или `refresh` | **обязательно**: без него токен продления принимается там, где ждут токен доступа, и живущее месяц удостоверение работает как живущее полчаса |
| `exp` | момент окончания | срок |
| `iat` | момент выпуска | по нему видно, что токен выпущен до понижения роли или до смены пароля |
| `jti` | уникальный идентификатор | отзыв конкретной сессии; ключ записи в БД |
| `iss`, `aud` | кто выпустил и для кого | токен, выпущенный для другой системы, не принимается в этой |
| `kid` (в заголовке) | идентификатор ключа подписи | замена ключа без разлогинивания: проверяющая сторона держит несколько ключей (см. секреты, раздел о ротации) |

Роль в токене — снимок на момент выпуска. Понижение роли начинает действовать не сразу, а через
срок жизни токена доступа. Варианта два: либо роль кладётся в токен и срок делается коротким, либо
роль читается из хранилища при каждой проверке (тогда проверка перестаёт быть локальной). Выбор
фиксируется явно, потому что от него зависит ответ на вопрос «через сколько подействует блокировка
учётной записи».

**Подпись.** Симметричная (`HS*`) означает, что тот, кто проверяет, может и выпускать: она
применима внутри одного сервиса. Как только удостоверение проверяют несколько сервисов,
используется пара ключей (`RS*`, `ES*`): закрытый — только у модуля авторизации, открытый
раздаётся проверяющим. Открытый ключ доставляется файлом рядом с сервисом либо ручкой модуля,
отдающей набор открытых ключей; во втором случае он кешируется и обновляется по `kid`.

При проверке ожидаемый алгоритм задаётся списком в коде, а не берётся из заголовка токена. Иначе
токен сам назначает способ своей проверки — подменив алгоритм на симметричный, предъявитель
подписывает токен известным ему открытым ключом.

---

## Срок жизни и продление

Схема — короткий доступ плюс отдельное продление:

```
вход ──▶ токен доступа (минуты) + токен продления (недели)
           │                          │
           │ истёк                    │
           ▼                          ▼
      ответ 401  ─── клиент зовёт ─▶ продление ──▶ новая пара, прежний токен продления отозван
```

**Продление ротируется.** В ответ на продление выдаётся не только новый токен доступа, но и новый
токен продления, а прежняя запись удаляется. Тогда предъявление уже использованного токена
продления — наблюдаемое событие: либо клиент потерял ответ, либо значение у кого-то ещё. Реакция на
такое предъявление — отказ и завершение всей цепочки сессий этого клиента.

**При истёкшем удостоверении сервис отвечает `401`.** Это единственный ответ, по которому клиент
понимает, что нужно продлить и повторить. Частая ошибка — вернуть `404`: обработчик сначала ищет
объект «от имени неизвестного пользователя», ничего не находит и отдаёт «не найдено». Клиент в
ответ показывает пустой экран вместо продления, а в журнале это выглядит как отсутствие данных, а
не как истечение срока. Проверка удостоверения обязана выполняться до поиска объекта.

Порядок на стороне клиента (базовый клиент BMFP, раздел «Базовый клиент»):

1. ответ `401` → одно продление → повтор исходного запроса;
2. параллельные запросы, получившие `401`, делят **одно** продление, а не начинают по своему:
   при ротации первое продление отзывает токен, и остальные получают отказ, выбивающий рабочую
   сессию;
3. `401` от самой ручки продления не приводит к новому продлению — иначе получается цикл; он
   означает конец сеанса: хранилище чистится, пользователь уводится на вход.

Ориентиры сроков собраны в таблице видов удостоверений. Их значения задаются настройкой, а не
константой в коде: срок — параметр установки, и в тестовой среде он другой.

---

## Единый вход между несколькими сервисами

Когда сервисов несколько, аутентификация выносится в отдельный модуль авторизации. Причины —
проверяемые:

- **пароль вводится на одном домене.** Каждый сервис со своей формой входа — это N мест, где
  вводится пароль, и N реализаций хранения отпечатка, ограничения попыток и восстановления доступа;
- **способы входа добавляются один раз.** Внешний провайдер, одноразовый код, вход из мини-аппа
  подключаются в модуле, а не в каждом сервисе;
- **ключ подписи и его замена — в одном месте.** Сервисы только проверяют подпись;
- **сессия общая.** Переход между сервисами не требует повторного входа.

Обмен устроен так, что удостоверение не передаётся через адресную строку: через неё идёт
одноразовый код, который обменивается на удостоверение отдельным запросом.

### Поток

```
потребитель                     браузер              модуль авторизации

1. проверочное значение
   и отпечаток от него
2. уход на вход            ──▶ перенаправление ──▶ 3. сверка потребителя
   (идентификатор,                                    и адреса возврата
    адрес возврата, state,                         4. вход человека
    отпечаток, метод)                              5. выпуск кода
6. возврат с кодом         ◀── перенаправление ◀── (на адрес возврата)
7. обмен: код + проверочное значение ───────────▶ 8. сверка отпечатка,
   (прямой запрос, минуя браузер)                    код удаляется,
   удостоверение           ◀─────────────────────── выдаётся удостоверение
9. очистка адресной строки
```

1. Фронт потребителя создаёт случайное **проверочное значение** (43–128 символов), кладёт его в
   хранилище своей вкладки и считает от него отпечаток `sha-256` в кодировке base64url.
2. Уход на страницу входа модуля с параметрами: идентификатор потребителя, адрес возврата,
   `state` (случайное значение для сверки при возврате), отпечаток проверочного значения и имя
   метода (`S256`).
3. Модуль проверяет: потребитель есть в реестре; переданный адрес возврата **точной строкой**
   совпадает с одним из перечисленных для этого потребителя. При расхождении — отказ на странице
   модуля, **без перенаправления**: перенаправление по непроверенному адресу и есть та самая
   уязвимость, от которой защищает перечень.
4. Человек входит на домене модуля. Если у модуля уже есть действующая сессия, шаг проходит без
   ввода.
5. Модуль создаёт одноразовый код — случайное значение и запись во временном хранилище:
   код → {учётная запись, потребитель, отпечаток проверочного значения, адрес возврата}. Срок
   записи — десятки секунд: код нужен ровно на один переход.
6. Перенаправление на адрес возврата с `code` и `state`.
7. Фронт потребителя сверяет `state` со своим (иначе принимается возврат, начатый не им), затем
   отправляет код и проверочное значение на обмен — через свой шлюз, с того же источника.
8. Модуль **сначала удаляет запись** («прочитать и удалить» одной операцией), затем сверяет
   потребителя и `sha-256` от присланного проверочного значения с сохранённым отпечатком. Любое
   расхождение — отказ. Удаление до сверки, а не после, делает код одноразовым и при двух
   параллельных обменах.
9. Фронт убирает `code` и `state` из адресной строки заменой записи истории и удаляет проверочное
   значение: адрес со следами обмена не остаётся в истории браузера и в поле «источник перехода»
   следующих запросов.

Привязка кода к инициатору через отпечаток проверочного значения известна как PKCE (RFC 7636).
Она отвечает на конкретную угрозу: код проходит через адресную строку, историю браузера, журналы
промежуточных прокси и заголовок источника перехода, и перехваченный код без проверочного значения
бесполезен.

### Почему перечень адресов возврата — точные строки

Маска по поддомену (`*.example.com`) выглядит удобно: сервисы живут на поддоменах одного домена,
и перечень не нужно править при добавлении сервиса. Она же снимает защиту.

Привязка к инициатору не помогает в случае, когда инициатором выступает посторонний. Страница на
любом поддомене, попадающем под маску, начинает вход со своим проверочным значением и своим адресом
возврата. Человек, у которого в модуле уже есть действующая сессия, перенаправляется молча — ввод
не требуется. Код приходит инициатору, и он обменивает его законным образом, потому что проверочное
значение у него своё. Итог — сессия человека у постороннего.

Поддомен, попадающий под маску, появляется буднично: поддомен, выданный пользователям под их
страницы; поддомен, направленный записью `CNAME` на внешний сервис; поддомен, забытый после
закрытия проекта, чей адрес освободился и достался другому. Ни один из этих случаев не выглядит как
взлом, и ни один не заметен со стороны модуля авторизации.

Отсюда правила сверки:

- перечень — **точные строки целиком**, вместе со схемой, хостом, портом и путём. Не только хост:
  открытый редирект на самом сервисе-потребителе (`/уйти?куда=…`) уводит код дальше по цепочке;
- сравнение — посимвольное, без нормализации и без «с хвостовым слэшем тоже подойдёт». Параметры
  запроса в адресе возврата либо запрещены, либо входят в сверяемую строку;
- перечень задаётся **на каждого потребителя**, а не общим списком: код, выданный одному
  потребителю, не должен уходить на адрес другого.

### Реестр потребителей

| Поле | Что | Зачем |
|---|---|---|
| идентификатор | строка | по нему выбирается запись при уходе на вход и при обмене |
| адреса возврата | перечень точных строк | куда разрешено вернуть код |
| секрет потребителя | значение, предъявляемое при обмене | применимо к потребителям, у которых есть серверная часть и есть где хранить секрет; для потребителя, целиком живущего в браузере, секрет хранить негде, и его роль выполняет привязка к инициатору |
| состояние | включён / отключён | отключение потребителя не требует удаления записи и истории |

Реестр — данные, а не код. Если он задан переменной окружения, добавление потребителя требует
перезапуска модуля, а история изменений не ведётся; таблица в БД снимает оба ограничения.

### Что проверяет сервис-потребитель

Получив удостоверение, сервис проверяет его сам. Способа два:

| Способ | Что нужно | Цена |
|---|---|---|
| локально по открытому ключу | открытый ключ и `kid` | нет сетевого вызова; отзыв действует через срок жизни токена доступа |
| запросом к модулю авторизации | доступ к модулю по внутренней сети | отзыв действует сразу; сетевой вызов на каждый запрос — обычно с коротким кешем результата |

Ручки модуля, которые нужны соседним сервисам (проверка удостоверения, сведения об учётной записи),
и ручки, которые нужны странице входа (вход, обмен, продление), — разные наборы. Они разводятся по
разным шлюзам: шлюз для соседних сервисов наружу не публикуется и содержит только первый набор
(BMGP: состав шлюза определяется со стороны потребителя).

---

## Правила доступа

### Роль и право

**Роль** — имя группы учётных записей (`user`, `support`, `admin`). Ею проверяются действия, право
на которые не зависит от конкретного объекта: «смотреть сводку», «заводить сотрудников». Проверка —
принадлежность роли перечню, объявленному на обработчике.

**Уровень доступа к объекту** — то, что выдаётся на конкретный объект или ветку дерева объектов:
`чтение < запись < управление`. Проверка — «уровень не ниже требуемого». Уровни наследуются вниз по
дереву: выдача на узел действует на всё, что под ним.

Ролей хватает, пока перечень действий короткий и однозначно делится между двумя-тремя группами. Как
только появляется раздача доступа к отдельным объектам (совместная работа, организации, команды),
роль остаётся признаком учётной записи целиком, а решение по объекту принимает уровень доступа.
Промежуточный вариант — именованные права (`orders.refund`), собранные в роли: роль тогда является
набором прав, а проверка идёт по праву. Он оправдан, когда набор действий большой, а групп
пользователей много; для трёх ролей он добавляет слой без выигрыша.

Роли и уровни — доменные перечисления в `core/domains/enums`, а не свободные строки. Свободная
строка допускает запись значения с опечаткой: проверка «роль в перечне» тихо перестаёт совпадать.

### Владение объектом

Владение проверяется в `core`, вместе с чтением объекта, одним запросом:

```sql
SELECT … FROM orders WHERE id = :id AND owner_id = :account_id
```

Не «прочитать объект, потом сравнить владельца в обработчике» — сравнение легко забыть в новом
сценарии, и `api` начинает содержать правило доступа. Не «спросить право отдельным запросом, потом
прочитать» — между двумя запросами объект может сменить владельца, и лишний проход по хранилищу
выполняется на каждом обращении.

Ноль строк в результате означает одно и то же для двух разных случаев: объекта нет и объект чужой.
Это и есть требуемое поведение.

### Одинаковый ответ на «чужой» и «нет»

Если чужой объект даёт `403`, а несуществующий — `404`, то перебор идентификаторов отвечает на
вопрос, какие объекты существуют. Этого достаточно, чтобы узнать число заказов в системе, темп их
появления, а по именам — состав чужих проектов. Содержимое при этом не раскрывается, раскрывается
факт существования — а скрывают обычно именно его.

Правило: **для объекта, о существовании которого предъявитель не должен знать, ответ на «чужой» и
на «нет» одинаков — `404`.** Одинаков не только код, но и тело ответа: машинный код, текст,
заголовки.

Исключение определяется тем же критерием. Если предъявитель уже знает, что объект есть — он участник
организации, он видит объект в перечне, он получил ссылку от владельца, — скрывать существование
нечего, и правильный ответ `403`: он объясняет, чего не хватает, и не выглядит как «страница
пропала».

| Ситуация | Ответ |
|---|---|
| приватный объект, обратившийся не имеет к нему доступа (в том числе аноним) | `404` |
| объект виден обратившемуся, но текущее действие требует более высокого уровня | `403` |
| объект виден, действие запрещено состоянием объекта (заказ уже оплачен) | `409` |
| ссылка на объект внутри чужого недоступного объекта | `404` — как у родителя |

При изменяющих операциях уровень доступа проверяется **до** проверки существования объекта. Иначе
ответы на «нет прав, объект есть» и «нет прав, объекта нет» различаются, и правило нарушается тем
же перебором.

Различие может проявиться и во времени ответа: проверка, выполняющая запрос к хранилищу только для
существующих объектов, отвечает на них дольше. Это существенно там, где перебор дёшев; время имеет
смысл сверять после того, как выровнены коды и тела.

---

## Ответы при отказе

Форма — общий конверт (BMBP): `{ status, error_code, details }`. Клиент ветвится по `error_code`,
человеку показывается `details`.

| Ситуация | Код | Машинный код |
|---|---|---|
| удостоверение не предъявлено | `401` | `unauthorized` |
| предъявлено, но недействительно или истекло | `401` | `unauthorized` |
| предъявлено и действительно, действие не разрешено (существование объекта не скрывается) | `403` | `forbidden` |
| объект недоступен, и знать о его существовании не положено | `404` | `not_found` |
| слишком много попыток входа или продления | `429` + `Retry-After` | `rate_limit` |

Разделение `401` и `403` держится строго: `401` означает «предъяви удостоверение или продли»,
`403` — «удостоверение принято, этого всё равно нельзя». Клиент, получивший `403`, продлевать не
должен: продление не изменит результат, а цикл продлений выбьет рабочую сессию.

**Причина отказа во входе не уточняется.** «Учётной записи нет» и «пароль не подошёл» — один и тот
же ответ, иначе форма входа превращается в способ проверять, зарегистрирован ли адрес. То же
относится к восстановлению доступа: ответ одинаков для известного и неизвестного адреса.

**Заголовок `WWW-Authenticate`** ставится там, где клиент — программа, знающая протокол (например,
клиент системы контроля версий): без него он не поймёт, что нужно предъявить учётные данные. Для
API, которым пользуется веб-интерфейс, заголовок обычно не ставят: браузер в ответ на него
показывает системное окно ввода поверх приложения.

---

## Чего не делают

**Не пишут свой алгоритм подписи и не изобретают формат удостоверения.** Берут реализацию из
библиотеки языка. Ошибки такого кода не видны на успешном пути: приём токена без подписи, приём
симметричной подписи там, где ожидался открытый ключ, сравнение подписи с ранним выходом.

**Не берут алгоритм проверки из самого токена.** Список допустимых алгоритмов задаётся в коде.

**Не держат секрет в коде** — включая значение по умолчанию в конфигурации и правдоподобную
заглушку вида `change_me_in_prod`: с ней система запускается, и то, что ключ подписи известен
всем, обнаруживается при разборе инцидента. Правила хранения — в [секретах](../operations/secrets.md).

**Не доверяют заголовку, пришедшему снаружи.** Заголовок вида `X-User-Id`, проставленный
внутренним компонентом, неотличим от такого же заголовка, присланного клиентом, — если он не
вырезан на границе. Заголовки личности обнуляются на шлюзе явно (`proxy_set_header X-User-Id ""`),
а сервис их не читает вовсе: единственный вход личности — заголовок авторизации, который сервис
проверяет сам.

**Не считают проверку в интерфейсе проверкой.** Скрытая кнопка убирает действие с экрана, но не из
API: тот же запрос отправляется вручную. Интерфейс скрывает недоступное ради понятности, решение
принимает сервер.

**Не хранят пароли, машинные токены и одноразовые коды в открытом виде** и не пишут их в журнал —
ни в теле запроса, ни в тексте исключения ([наблюдение](../operations/observability.md)).
Одноразовый код, попавший в журнал, перестаёт быть одноразовым: журнал живёт дольше канала
доставки кода.

**Не сравнивают удостоверения обычным сравнением строк.** Сравнение, прекращающееся на первом
несовпавшем байте, занимает разное время в зависимости от того, сколько байтов совпало. Для
значений, которые предъявитель может подбирать по байту (внутренний токен, отпечаток кода),
используется сравнение в постоянное время.

**Не генерируют коды и токены обычным генератором псевдослучайных чисел.** Его вывод предсказуем по
предыдущим значениям; используется криптостойкий источник.

---

## Проверки соответствия

Ниже `$base` — адрес проверяемого сервиса, `$token_*` — заранее полученные удостоверения.
Проверки выполняются по сервису, а не по шлюзу: шлюз может закрывать ручку перечнем маршрутов, но
это не заменяет её собственного требования доступа.

### 1. Каждая изменяющая операция требует удостоверения

По спецификации API — операции, у которых требование не объявлено:

```bash
curl -s "$base/openapi.json" \
| jq -r '.paths | to_entries[] as $p | $p.value | to_entries[]
         | select(.key | test("^(post|put|patch|delete)$"))
         | select(((.value.security // []) | length) == 0)
         | "\(.key | ascii_upcase) \($p.key)"'
# ожидается: пустой вывод
```

Пустой вывод здесь означает «нечего показать», а не «всё закрыто»: если требование объявлено не
зависимостью, а промежуточным слоем, оно в спецификацию не попадает. Прямая проверка — позвать
каждую изменяющую операцию без удостоверения:

```bash
curl -s "$base/openapi.json" \
| jq -r '.paths | to_entries[] as $p | $p.value | to_entries[]
         | select(.key | test("^(post|put|patch|delete)$")) | "\(.key) \($p.key)"' \
| while read -r method path; do
    url="$base$(printf '%s' "$path" | sed 's/{[^}]*}/1/g')"
    code=$(curl -s -o /dev/null -w '%{http_code}' -X "$(printf '%s' "$method" | tr 'a-z' 'A-Z')" "$url")
    case "$code" in 401|429) ;; *) printf '%s %s → %s\n' "$method" "$path" "$code" ;; esac
  done
# ожидается: пустой вывод
```

Ответ `422` в этом списке означает, что разбор тела выполняется раньше проверки удостоверения:
такую операцию проверяют отдельно, послав корректное тело. Ответ `200` или `404` — дефект: операция
выполняется или ищет объект без предъявленного удостоверения.

### 2. Приватный объект не отдаётся анонимно

```bash
curl -s -o /dev/null -w '%{http_code}\n' "$base/api/front/items/$private_id"
# ожидается: 404

curl -s -o /dev/null -w '%{http_code}\n' \
     -H "Authorization: Bearer $token_owner" "$base/api/front/items/$private_id"
# ожидается: 200
```

Ответ `401` на первый запрос корректен только тогда, когда ручка требует удостоверения для любого
чтения: он одинаков для существующего и несуществующего объекта. Если та же ручка отдаёт публичные
объекты анонимно, `401` на приватном отличает его от несуществующего — и это дефект.

### 3. Чужой объект неотличим от несуществующего

```bash
a=$(curl -s -H "Authorization: Bearer $token_other" "$base/api/front/items/$foreign_id")
b=$(curl -s -H "Authorization: Bearer $token_other" "$base/api/front/items/999999999")
[ "$a" = "$b" ] && echo "совпадает" || printf '%s\n%s\n' "$a" "$b"
# ожидается: совпадает
```

### 4. Истёкшее удостоверение даёт `401`, а не `404`

Токен с истёкшим сроком берут из тестовой среды (выпуск с `exp` в прошлом) либо дожидаются
окончания срока.

```bash
curl -s -H "Authorization: Bearer $token_expired" "$base/api/front/orders" \
| jq -r '.error_code'
# ожидается: unauthorized
```

Тот же случай проверяется тестом: подмена времени выпуска, ожидаемый код ответа `401`. Ручной
проверки недостаточно — она не повторяется на каждом изменении.

### 5. Заголовок личности снаружи не проходит

```bash
# сервис не читает заголовки личности
grep -rniE 'x-(user|account|remote|auth[a-z]*)[-_]' app/ | grep -v '/tests/'
# ожидается: пустой вывод

# граница обнуляет их явно
grep -rn 'proxy_set_header X-User-Id' proxy/conf.d/
# ожидается: строка вида: proxy_set_header X-User-Id "";

# живая проверка через шлюз
curl -s -o /dev/null -w '%{http_code}\n' -H 'X-User-Id: 1' "https://example.com/api/profile"
# ожидается: 401
```

### 6. Удостоверение одной среды не действует в другой

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
     -H "Authorization: Bearer $token_from_test_env" "https://example.com/api/front/orders"
# ожидается: 401
```

Ответ `200` означает общий ключ подписи. Сами значения ключей при этом не сверяются: достаточно
этой проверки и сверки отпечатков (секреты, раздел о проверке значений).

### 7. Единый вход: чужой адрес возврата отвергается

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
     "https://example.com/authorize?client=demo&return_to=https://other.example.net/catch"
# ожидается: 400; ответ 302 означает, что перенаправление происходит до сверки

curl -s -o /dev/null -w '%{http_code}\n' \
     "https://example.com/authorize?client=demo&return_to=https://sub.example.com/catch"
# ожидается: 400, если этой строки нет в перечне потребителя
```

Повторный обмен тем же кодом:

```bash
curl -s -X POST "$base/api/front/sso/exchange" -H 'Content-Type: application/json' \
     -d "{\"code\":\"$code\",\"client\":\"demo\",\"verifier\":\"$verifier\"}" -o /dev/null -w '%{http_code}\n'
curl -s -X POST "$base/api/front/sso/exchange" -H 'Content-Type: application/json' \
     -d "{\"code\":\"$code\",\"client\":\"demo\",\"verifier\":\"$verifier\"}" -o /dev/null -w '%{http_code}\n'
# ожидается: 200, затем 401
```

### 8. Право проверено на сервере, а не на кнопке

Берётся учётная запись без права и вызывается ручка напрямую, минуя интерфейс:

```bash
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
     -H "Authorization: Bearer $token_without_right" "$base/api/front/items/$id"
# ожидается: 403 (или 404, если объект скрыт от этой учётной записи)
```

Проверка выполняется по перечню: каждое действие, которое интерфейс скрывает или показывает в
зависимости от роли или владения, вызывается напрямую под учётной записью, от которой оно скрыто.
Перечень собирается по условиям отображения в интерфейсе — их обычно на порядок меньше, чем ручек.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| после истечения срока клиент показывает пустой экран, в ответах `404` | объект ищется до проверки удостоверения; «не найдено от имени никого» отдаётся как «не найдено» | проверять удостоверение на входе обработчика, до обращения в `core`; ответ `401` |
| в ответах `500` вместо `401` | разбор токена бросает исключение библиотеки, оно не поймано и уходит в общий обработчик как непредвиденное | ловить ошибку разбора в месте проверки и переводить в стандартный отказ |
| при отсутствующем заголовке ответ приходит вне конверта и с кодом `403` | отказ формирует схема безопасности фреймворка, минуя каталог ошибок | привести отказ схемы к общему конверту и коду `401`; проверять по телу ответа, а не только по коду |
| приватный ответ достался другому пользователю | ответ закеширован на шлюзе с ключом без учёта удостоверения | включить заголовок авторизации в ключ кеша (BMGP, раздел о сквозном), ответы с личными данными помечать как непубличные |
| ограничение попыток срабатывает сразу на всех пользователей | адрес клиента берётся из соединения, а за шлюзом это адрес шлюза | брать адрес из проброшенного заголовка и доверять этому заголовку только от своего шлюза (заголовок от внешнего клиента вырезается на границе) |
| клиент бесконечно продлевает удостоверение | `401` от самой ручки продления обрабатывается тем же перехватчиком | исключить ручку продления из перехватчика; её `401` означает конец сеанса |
| после недолгого простоя вкладка разлогинивается | несколько параллельных запросов начали по своему продлению, ротация отозвала токен первого | одно продление на клиент: параллельные ожидают его результат |
| токен продления принимается вместо токена доступа | в токене нет поля типа, вид определяется по наличию других полей | добавить `typ` и сверять его при проверке |
| в браузере работает, из машинного клиента — отказ | правило смотрит на cookie, машинный клиент предъявляет заголовок | разбор удостоверения — одна точка, принимающая оба вида; правило работает с проверенным обратившимся, а не с транспортом |
| перебор идентификаторов показывает, какие объекты существуют | чужой объект даёт `403`, несуществующий — `404` | для скрытых объектов один ответ `404`; сверить коды **и** тела |
| действие, скрытое в интерфейсе, выполняется запросом вручную | право проверено только на кнопке | перенести правило в `core`, кнопку оставить как подсказку |
| удостоверение, выпущенное в тестовой среде, действует в боевой | одинаковый ключ подписи в средах | разные ключи по средам; проверка 6; при совпадении значение считать раскрытым и заменить |
| после замены ключа подписи все разлогинились | проверяющая сторона знает один ключ | ротация с несколькими действующими ключами и `kid` (секреты, раздел о ротации) |
| посторонний получил сессию, в журналах модуля вход выглядит обычным | перечень адресов возврата задан маской поддомена, вход инициирован чужим поддоменом | точный перечень строк на каждого потребителя; проверка 7 |
| код единого входа сработал дважды | запись кода удаляется после сверки, а не до | «прочитать и удалить» одной операцией до сверки |
| код виден в истории браузера и в поле источника перехода | адресная строка не очищена после обмена | заменить запись истории сразу после обмена |
| вход работает локально, в рабочем контуре cookie не ставится | признаки `Secure` и `SameSite` не соответствуют схеме и доменам | признаки задаются настройкой; на `https` — `Secure`; при разных доменах входа и приложения — соответствующий `SameSite` со сверкой источника |
| пользователи жалуются на отказ входа после нескольких попыток, в ответе `403` | ограничение частоты отвечает кодом «запрещено» | `429` и `Retry-After`: клиент отличает «нельзя» от «подожди» |
| по ответу формы входа видно, зарегистрирован ли адрес | «нет учётной записи» и «неверный пароль» — разные ответы | один ответ на оба случая |
| сервис доступен в обход шлюза, и его ручки отвечают | сервис опубликован наружу | закрыть публикацию ([сетевой контур](../operations/network-topology.md)); требование доступа на обработчиках при этом остаётся обязательным |
| в журнале обнаружились удостоверения | пишется тело запроса или заголовки целиком | убрать поля из записи, засветившиеся значения отозвать ([наблюдение](../operations/observability.md)) |

---

Документ: http://docs.gitaspen.ru/development/architecture/PRINCIPLES

# Принципы разработки

Как мы строим всё. Коротко — чтобы пересказать своими словами; полно — чтобы закрывать споры.
Это верхний слой; «как именно» — в спеках рядом: [BMAP](./BMAP.md) (приложение целиком),
[BMFP](./BMFP.md) (фронт), [BMBP](./BMBP.md) (бэкенд), [BMGP](./BMGP.md) (шлюз),
[REUSE](./REUSE.md) (переиспользование).

> Всё — самостоятельный кусок на контракте, у которого два равных пользователя — человек и ИИ:
> оба действуют одной логикой и видят одно и то же.
> Собираем из готового, держим локально под git, а часть отселяем в свой дом, когда переросла место.
> Открыто и честно.

**Ничто не приколочено.** Фича — это модуль на контракте: что-то принимает, что-то отдаёт. Не
прибита туда, где родилась. Это даёт системе расти — добавлять функционал, менять и масштабировать
части по отдельности; а заодно потом легко вынести и переиспользовать.

**Один движок — много оболочек.** Ядро не знает, где работает; привязка к хосту (ОС, сайт, киоск) —
тонкая оболочка вокруг. Один движок разметки служит и браузеру, и оболочке операционной системы —
меняется только оболочка, ядро остаётся тем же.

**Два входа, один результат.** Пользователей теперь двое — человек и ИИ. Одна поверхность, две
двери: каждая способность сначала команда (эндпоинт), потом кнопка — кнопка, тест и ИИ зовут одну
функцию, второй реализации нет. Внешний ИИ ходит через MCP-сервер внутри самого приложения.

**ИИ видит рендер, не экран.** ИИ-агенту дать то же, что видит человек, из самого приложения:
состояние — структурой (`state()`), без скрейпинга DOM; картинку — приложение само рендерит свой
DOM в изображение (html-to-image), поэтому видно даже скрытое / свёрнутое / перекрытое окно.
Системный скриншот не годится — он видит только то, что на экране, и отдаёт пиксели без смысла.
Приложение публикует состояние структурой (`window.<app>.state()`) и описывает эту поверхность
отдельным документом в своём репозитории.

**Контракт на границе.** Стороны связаны формой данных, а не общим кодом. Любую сторону — модуль,
бэк, фронт — меняешь, не трогая другую, пока контракт держится.

**Не под один случай.** Костыль — это когда кусок знает про своего конкретного хозяина. Знание о
хозяине живёт в хозяине, а не внутри куска.

**Перерос — отселяй.** Корень — расширяемость: модульно строим сразу, а кусок получает свой дом
(репо, пайплайн), когда перерос место — либо понадобился второму потребителю, либо вырос в
самостоятельную часть со своим жизненным циклом. Не раньше (абстракция под воображаемое — костыль),
но и не держим силой, когда уже перерос.

**Бери готовое, смотри рядом.** Зрелая либа и соседний проект — вперёд самопала. Переиспользуем не
только код, но и решения.

**Локально и под git с первого файла.** Кода вне git нет, всё работает офлайн. Коммит — тоже
модуль: цельный, сам по себе рабочий, не ломает прошлое.

**Открыто и честно.** Опенсорс без обмана. Сама вещь — бесплатна и открыта; платишь за масштаб —
парк, удалённое управление. Защищает лицензия, а не закрытый код.

---

Документ: http://docs.gitaspen.ru/development/design/codex-design-rules-skill/SKILL

---
name: design-rules
description: UI/UX product design rules for building or reviewing modern interfaces, websites, mobile apps, dashboards, fintech flows, AI tools, visual systems, design systems, and frontend outputs. Use when the task involves interface design quality, UX review, product UI decisions, visual hierarchy, spacing, typography, color, interaction states, accessibility, Tailwind/shadcn cleanup, graphics/3D/isometry, or turning rough design requests into usable product interfaces.
---

# Design Rules

Derived version of the library's `design/DESIGN_RULES.md`, packaged in English for installation as a skill; that file is the source of truth, and changes go there first.

Use these rules as a compact operating system for UI/UX work. Prefer product clarity over decorative polish.

## Workflow

1. Identify product context: user, task, object, frequency, risk, platform, desired feeling, metric.
2. Define structure before style: IA, navigation, main object, primary action, secondary actions.
3. Design behavior: feedback, loading, empty, error, success, disabled, focus, offline/sync, saved states.
4. Build visual system: hierarchy, spacing, typography, color roles, surfaces, shape, motion.
5. Add brand and graphics only where they support the scenario.
6. Check accessibility, responsiveness, performance, real data, long strings, and edge cases.
7. Reject default UI-kit output unless it has a product-specific system and states.

## Non-Negotiables

- One primary action per context.
- Action belongs near the object it changes.
- Spacing expresses meaning: inside element < inside group < between groups < between sections.
- Color is a semantic role, not decoration.
- Text labels must explain outcomes, not say OK/Submit when specific action text is possible.
- Loading, empty, error, success, disabled, focus, hover/pressed states are part of the design.
- Critical flows must be familiar, explicit, recoverable, and accessible.
- Accessibility is part of component readiness, not a final pass.
- Product screenshots, graphics, and 3D must perform a job.

## Avoid

- Tailwind/shadcn clone aesthetic.
- Card soup and nested cards.
- Decorative 3D blobs or generic SaaS isometry.
- Landing-page layout inside dashboards/admin tools.
- Hover-only actions with no keyboard/mobile fallback.
- Icon-only mystery actions.
- Dark premium trap with poor contrast.
- AI sparkle instead of AI workflow.
- Search as a cover for bad IA.
- Dashboards that are chart cemeteries.
- Empty/loading/error states left for later.

## Visual Rules

- Use 3-5 text roles for most interfaces.
- Keep accent color rare; if everything is accent, nothing is.
- Meet WCAG contrast: 4.5:1 normal text, 3:1 large text and UI objects.
- Use semantic color tokens: text, surface, border, primary, danger, success, warning, focus.
- Use a spacing scale, typically 4/8-based, with optical corrections when needed.
- Use surfaces/elevation to explain layering, not decoration.
- Motion should explain feedback, continuity, or relationship.

## Graphics Rules

- 3D: object, material, tactility, memorable hero.
- Isometry: systems, workflows, architecture, relationships.
- Photo: reality, people, place, trust.
- Product screenshot: proof of product value.
- Pictogram: quick concept.
- Motion: state and transition.

## AI UI Rules

AI features need workflow states:

- input;
- retrieving/thinking;
- generating/streaming;
- tool call;
- draft result;
- sources/provenance when accuracy matters;
- edit;
- accept/apply;
- reject/regenerate;
- undo;
- failure/retry;
- privacy/data note.

Do not default to chat-only UI. Prefer hybrid UI when users need precise control.

## Review Checklist

Ask:

- Is the user/task/context clear?
- Is the main object obvious?
- Is the main action obvious?
- Are secondary/risky actions quieter or separated?
- Does spacing show grouping correctly?
- Does color encode roles and pass contrast?
- Are all states designed?
- Does the interface respond immediately?
- Can the user recover from mistakes?
- Does it work on mobile, keyboard, screen reader, long text?
- Does the brand help the scenario?
- Does it avoid generic UI-kit output?

When unsure, prefer live product behavior, platform conventions, and clear recovery over visual novelty.

---

Документ: http://docs.gitaspen.ru/development/design/DESIGN_RULES

# DESIGN RULES

Версия: 2026-06-22.

Назначение: компактные правила для проектирования, ревью и генерации современных интерфейсов. Это главный практический файл: его можно давать человеку, дизайнеру, нейросети или агенту перед задачей на UI/UX.

---

## 1. Главный принцип

Интерфейс строится не вокруг экрана, компонента, Tailwind-классов или модного стиля. Интерфейс строится вокруг:

```text
пользователь -> контекст -> задача -> объект -> действие -> состояние -> восстановление -> доверие
```

Красивый экран без поведения, состояний и понятной задачи считается незавершенным.

---

## 2. Сначала определить продуктовую рамку

Перед дизайном ответить:

- кто пользователь;
- где и когда он использует интерфейс;
- что он хочет сделать;
- какой главный объект на экране;
- какое главное действие;
- как часто действие повторяется;
- насколько действие рискованное;
- что должно быть понятно за 3 секунды;
- какой desired feeling: доверие, скорость, премиальность, спокойствие, азарт, технологичность;
- какая метрика качества: time-to-action, conversion, error rate, support load, retention, INP.

Если рамки нет, дизайн почти всегда превращается в шаблон.

---

## 3. Иерархия важнее украшения

На каждом screen/state должен быть один главный фокус.

Правила:

- один primary action в контексте;
- вторичные действия визуально тише;
- destructive action отделять;
- цвет акцента использовать редко;
- если выделено все, не выделено ничего;
- экран должен читаться прищуром до чтения текста.

Проверки:

- blur/squint test;
- grayscale test;
- 3-second test;
- keyboard focus order.

---

## 4. Spacing и группировка

Отступы выражают смысл:

```text
внутри элемента < внутри группы < между группами < между секциями
```

Ориентир:

- 4 px: icon + label, micro gap;
- 8 px: поля внутри control;
- 12-16 px: элементы одной группы;
- 24 px: разделение групп;
- 32-40 px: крупные блоки;
- 48-64 px: секции.

Правила:

- label ближе к своему input, чем к соседнему;
- action ближе к объекту, который меняет;
- не лечить плохую группировку цветом;
- не делать card для каждой группы;
- card нужна, когда есть смысловая рамка или повторяемый item.

---

## 5. Цвет

Цвет в UI - это роль, а не украшение.

Минимальные роли:

- background;
- surface;
- text;
- muted text;
- border;
- primary action;
- selection;
- focus;
- success;
- warning;
- danger;
- info;
- disabled.

60/30/10 использовать только как sanity check:

```text
60% спокойная база
30% структура/поддержка
10% акцент/действие/статус
```

Контраст важнее палитры:

- обычный текст: минимум 4.5:1;
- крупный текст: минимум 3:1;
- UI components/иконки: минимум 3:1.

Цвет не должен быть единственным носителем смысла.

---

## 6. Типографика

Интерфейс читают, а не рассматривают.

Правила:

- 3-5 текстовых ролей достаточно для большинства UI;
- body должен быть читаемым, не декоративным;
- display text использовать редко;
- line-height body примерно 1.4-1.6;
- длинный текст не растягивать на всю ширину;
- числа в таблицах выравнивать и делать сканируемыми;
- labels важнее placeholder.

---

## 7. Поведение интерфейса

Каждое действие должно иметь цепочку:

```text
before -> immediate feedback -> processing state -> result -> recovery
```

Обязательные состояния:

- default;
- hover, где есть pointer;
- focus-visible;
- pressed/active;
- disabled;
- loading/submitting;
- success/saved;
- error/retry;
- empty;
- offline/sync, если данные сетевые;
- permission, если нужен доступ.

Если пользователь нажал кнопку и за 100-200 мс ничего не увидел, интерфейс ощущается сломанным.

---

## 8. Loading, empty, error

Loading:

- skeleton для известной структуры контента;
- spinner только для короткого неопределенного ожидания;
- progress bar для измеримого процесса;
- streaming/partial results для AI, поиска, генерации, аналитики.

Empty state:

- first use;
- no results;
- filtered empty;
- permission empty;
- offline empty;
- system issue.

Не смешивать эти пустоты одним текстом.

Error:

- рядом с проблемным полем;
- объясняет что случилось;
- говорит как исправить;
- не очищает введенное;
- не обвиняет пользователя;
- дает retry/recovery.

Плохая ошибка: `Что-то пошло не так`.

---

## 9. Inline actions

Правило:

```text
action belongs near the object it changes
```

Inline правильно:

- rename рядом с названием;
- copy/share рядом со значением;
- row actions в таблице;
- validation рядом с field;
- undo рядом с result/toast;
- save state рядом с редактируемым блоком.

Отдельная кнопка/toolbar/dialog нужны:

- для глобального действия;
- для рискованного действия;
- для bulk action;
- для сложного multi-step flow;
- когда действие должно быть очень заметным новичку.

---

## 10. Формы

Форма - это разговор, а не список полей.

Правила:

- visible labels;
- related fields группировать;
- helper text рядом с решением;
- validation не слишком рано;
- error summary для длинных форм;
- inline error рядом с field;
- правильная mobile keyboard;
- не очищать поля после ошибки;
- autosave/draft для длинного ввода;
- рискованные действия подтверждать или давать undo.

---

## 11. Навигация, поиск, фильтры

Разделять:

- navigation = куда идти;
- search = найти известное/примерное;
- filter = сузить набор;
- sort = изменить порядок;
- tabs = переключить view;
- chips = показать активные ограничения.

Search не должен быть костылем плохой информационной архитектуры.

Для поиска нужны:

- scope;
- suggestions;
- recent searches, если уместно;
- no results state;
- clear/reset;
- tokens/chips для сложных фильтров.

---

## 12. Графика

Графика должна иметь работу.

```text
3D          -> объект, материал, тактильность, вау
изометрия  -> система, процесс, связи
фото       -> реальность, доверие, люди, место
скриншот   -> доказательство продукта
пиктограмма -> быстрый концепт
motion     -> состояние, переход, feedback
```

Нельзя:

- generic 3D blob без смысла;
- изометрические человечки ради SaaS-декора;
- скрывать слабый продукт красивым mockup;
- смешивать разные asset styles без системы;
- ставить графику выше ясности.

---

## 13. Стиль

Стиль выбирать по задаче, а не по названию.

Оси:

- utility vs expressive;
- dense vs airy;
- platform-native vs branded;
- content-first vs control-first;
- trust-first vs excitement-first;
- human/tactile vs technical/precise.

Правило риска:

```text
риск выше -> интерфейс спокойнее, паттерны знакомее, copy прямее
риск ниже -> можно больше выразительности и эксперимента
```

Marketing surface может быть смелым. Core workflow должен быть ясным.

---

## 13.1 Визуальная выразительность и глубина

Правила выше дают понятный и работающий интерфейс, но не задают уровень визуальной проработки: при формально верной механике экран выходит плоским. Это отдельный слой работы — глубина светом вместо границ, контраст масштаба в типографике, слойность по Z, ограниченная палитра, качество ассетов, щедрый воздух.

Мера берётся из раздела 13: маркетинговая поверхность может быть выразительной, ядро рабочего процесса остаётся спокойным.

Приёмы, числа и чеклист «не пресно» — в [visual-craft-premium.md](./visual-craft-premium.md). Там же разобрано, почему слой не противоречит правилам этого файла: глубина даётся светом, а не вложением рамок и не карточкой в карточке.

---

## 14. AI UI

AI-фича должна быть workflow, а не магическая textarea.

Обязательные состояния:

- capability intro;
- input;
- retrieving/thinking;
- generating/streaming;
- tool call;
- partial result;
- draft;
- confidence/provenance, где нужна точность;
- edit;
- accept/apply;
- reject/regenerate;
- undo;
- failure/retry;
- privacy/data note.

Chat не всегда лучший интерфейс. Часто нужен hybrid UI: prompt + controls + preview + apply.

---

## 15. Accessibility

Accessibility - не финальный чеклист, а базовое ограничение.

Минимум:

- semantic HTML/native controls;
- keyboard navigation;
- visible focus;
- contrast;
- target size;
- labels;
- screen reader names;
- error identification;
- reduced motion;
- responsive reflow/zoom;
- color not alone.

Если component не доступен, component не готов.

---

## 16. Design system

Design system - не UI-kit, а язык решений.

Фиксировать:

- principles;
- color tokens;
- type tokens;
- spacing tokens;
- radius/elevation/motion tokens;
- component anatomy;
- variants;
- states;
- accessibility rules;
- examples;
- anti-examples;
- code mapping.

Имена токенов должны быть семантическими:

```text
color.text.danger, not red-500
space.group.md, not 24px-everywhere
```

---

## 17. Tailwind / shadcn / UI kits

Framework не является стилем.

```text
Tailwind = способ писать CSS
shadcn/Tailwind UI = заготовки
design system = свои решения
```

Плохо:

- дефолтные карточки;
- серые borders everywhere;
- random rounded-xl;
- одинаковые shadows;
- component soup;
- arbitrary values без системы;
- "нейронка сделала dashboard".

Хорошо:

- semantic tokens;
- свой layer компонентов;
- ограниченная spacing/type/color scale;
- documented states;
- переработанный бренд;
- real product flows.

---

## 18. Антипаттерны

Избегать:

- card soup;
- landing-page design inside dashboard;
- hover-only actions;
- icon-only mystery;
- generic SaaS isometry;
- dark premium trap;
- expressive everywhere;
- AI sparkle вместо AI UX;
- placeholder product screenshots;
- empty/error/loading forgotten;
- filters as tabs;
- search as excuse for bad IA;
- dashboard as chart cemetery;
- "красиво, но непонятно что делать".

---

## 19. Быстрый чеклист ревью

Перед сдачей спросить:

- пользователь и контекст ясны?
- главный объект понятен?
- главное действие видно?
- hierarchy читается за 3 секунды?
- spacing показывает связи?
- цвет имеет роли?
- текст читается?
- есть loading/empty/error/success?
- есть recovery/undo/retry?
- опасные действия отделены?
- mobile/focus/keyboard работают?
- доступность не сломана?
- графика выполняет работу?
- бренд помогает, а не мешает?
- UI не похож на дефолтный kit?
- продукт можно объяснить через живой сценарий?

---

## 20. Как ставить задачу на интерфейс

```text
ЦА:
Контекст:
Главная задача:
Главный объект:
Частота:
Риск:
Desired feeling:
Primary action:
Secondary actions:
Inline actions:
Global actions:
Состояния: loading / empty / error / success / offline / permission / AI / saved
Данные: реальные/примерные, длинные строки, пустые наборы
Ограничения: mobile, accessibility, platform, performance
Метрика качества:
Референсы по похожему сценарию:
Анти-референсы:
```

Если этого нет, нельзя требовать хороший UI.

---

Документ: http://docs.gitaspen.ru/development/design/RESPONSIVE

# RESPONSIVE — правило адаптива

Версия: 2026-06-29.

Одно правило, по которому адаптив пишется одинаково и хорошо во всех наших фронтах. Это предписание, не обзор возможностей CSS. На каждую задачу — один инструмент; развилок «можно так или так» здесь нет. Спутник `DESIGN_RULES.md`: там — *что* строить, здесь — как сделать это текучим и переносимым на нашем стеке (BMFP, React, Sass + CSS-переменные).

---

## Правило

**Компонент подстраивается под свой контейнер, не под экран.**

- Значение (размер, отступ, радиус, шрифт) — текучее, из токена. `clamp`, без query.
- Структура (стек ↔ строка, число колонок, скрыть) — переключается по ширине **контейнера**. `@container`.
- `@media` — только под физику устройства и каркас страницы, из закрытого списка §4.
- Внутри компонента про viewport не знают.

Почему так, а не брейкпоинтами по ширине экрана: брейкпоинт привязан к диагонали устройства, а компонент живёт в колонке, слоте, оболочке — их ширина к экрану отношения не имеет. Один и тот же виджет стоит в shell хаба, в MFE, в мини-аппе, в переносимом пакете — везде место разное. **Экран про реальную ширину компонента врёт, контейнер — нет.** Привязка к контейнеру — единственная, которая не ломается при переносе. Текучесть из токенов убирает дублирование значений. Всё остальное в этом файле — следствие этих двух фактов.

---

## 1. Порядок. Всегда сверху вниз

Это не выбор ветки по вкусу — это последовательность. Спускаешься на следующий уровень, только если предыдущий физически не решает задачу.

```text
1. Значение меняется плавно (размер / отступ / радиус / шрифт)   -> clamp из токена. Query нет.
2. Меняется структура (стек<->строка / число колонок / скрыть)   -> @container, по контейнеру.
3. Это физика устройства (палец, мышь, motion, высота, вырез)    -> @media / env, закрытый список §4.
4. Это каркас целой страницы (header, sidebar)                   -> @media на layout.
```

Пишешь внутри компонента `@media` по ширине — ты на неправильном уровне. Вернись на 1 или 2.

---

## 2. Значение — текучее, из токена

Значение живёт один раз — как CSS-переменная в `:root`. Компонент его не вычисляет, а берёт.

```scss
:root {
  --t-body: 1.02rem;
  --t-h1: clamp(2rem, 4.2vw, 3rem);
  --t-display: clamp(2.5rem, 6vw, 4.1rem);
  --s5: 24px;
  --r: 12px;
  --maxw: 1120px;
}
.title { font-size: var(--t-h1); }
```

Сетка считает число колонок сама, без брейкпоинтов «1 → 2 → 3»:

```scss
.grid {
  display: grid;
  gap: var(--s5);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
}
```

Обёртка-контейнер — без лишних слоёв:

```scss
.wrap { width: min(100%, var(--maxw)); margin-inline: auto; }
```

Текучесть **по контейнеру** (для переносимых компонентов) — единицы `cqi` (1% inline-size контейнера). Шрифт масштабируется от блока, а не от экрана:

```scss
.card { font-size: clamp(0.95rem, 4cqi, 1.25rem); }
```

Запрещено: число вместо токена в компоненте; брейкпоинт ради смены размера шрифта или отступа.

---

## 3. Структуру переключает контейнер

Контейнер-контекст объявляет родитель — лэйаут или регион:

```scss
.region { container: content / inline-size; }
```

Компонент внутри реагирует на ширину этого контейнера:

```scss
.card {
  display: grid;
  gap: var(--s4);

  @container content (min-width: 34rem) {
    grid-template-columns: auto 1fr;
  }
}
```

- Ось запроса — `inline-size`, не `size` (`size` требует фиксированной высоты и ломает блоки, растущие по контенту).
- Контейнер именовать — иначе запрос цепляется к ближайшему любому предку.
- Запрос смотрит на предка: контекст ставится на обёртку, адаптируется потомок. Сам себя контейнер не запрашивает.

Сменить раскладку компонента можно только так. Viewport для этого не используется никогда.

---

## 4. Физика устройства и каркас

`@media` разрешён **только** для перечисленного. Чего нет в списке — не повод для `@media`.

- `@media (hover: hover) and (pointer: fine)` — hover-эффекты только под мышь (`DESIGN_RULES.md` §7).
- `@media (pointer: coarse)` — крупный таргет под палец (§15).
- `@media (prefers-reduced-motion: reduce)` — снять/упростить анимацию (§15).
- `@media (prefers-color-scheme: dark)` — системная тема, если её не переключаем сменой токенов.
- `@media (min-width: …)` на лэйауте — каркас страницы: схлопнуть header, убрать sidebar.

Единицы экрана (это не `@media`, но та же физика):

- Высота под экран — `dvh` / `svh`, не `vh` (учитывают адресную строку).
- Края под вырез и таб-бар — `env(safe-area-inset-*)` + `viewport-fit=cover` (мобайл, TG-mini-app).

Ширина для раскладки компонента в этом списке отсутствует намеренно — она в §3.

---

## 5. Единицы — одна на роль

Одна единица на роль, альтернативы нет.

- Шрифт, отступы, радиусы → `rem`, через токен. `px` для шрифта запрещён — ломает zoom и reflow (§15).
- Хайрлайны, бордеры, жёсткие константы → `px`.
- Колонки и доли → `fr`, `%`, `minmax`, `auto-fit/fill`.
- Текучесть по контейнеру → `cqi` / `cqb`.
- Высота под экран → `dvh` / `svh`.
- Оси, отступы, скругления → логические свойства: `inline-size`, `margin-inline`, `padding-block`, `inset-inline`, `border-start-start-radius`. Физические `left/right/top/bottom` запрещены.

---

## 6. Размещение по слоям BMFP

| Слой | Ответственность за адаптив |
|---|---|
| `boundary/__styles/` | единственный источник: токены (текучие шкалы) + миксины. Другой абстракции нет. |
| `boundary/layouts/` | объявляют контейнер-контексты (`container-type`) и каркасные `@media` страницы. |
| `boundary/components`, `widgets/` | каждый `*.module.scss` реагирует на свой контейнер через `@container`, значения берёт из токенов. Viewport-`@media` внутри нет. |

Компонент, привязанный к контейнеру, корректен в любой оболочке — это и есть условие переносимости MFE и пакетов.

---

## 7. Канон SCSS — один на все фронты

Один источник. Разрозненные `respond()` / `breakpoint()` по проектам — удалить.

`boundary/__styles/_tokens.scss` — текучие шкалы как CSS-переменные. Имена семантические, не `red-500` (`DESIGN_RULES.md` §16).

`boundary/__styles/_responsive.scss` — четыре миксина, не больше:

```scss
@mixin container($name)    { container: #{$name} / inline-size; }
@mixin cq($name, $min)     { @container #{$name} (min-width: #{$min}) { @content; } }
@mixin coarse              { @media (pointer: coarse) { @content; } }
@mixin reduced             { @media (prefers-reduced-motion: reduce) { @content; } }
```

```text
компонент адаптируется -> @include cq(...)   (контейнер)
страница / shell        -> @media            (вьюпорт)
```

---

## 8. Запрещено

- Брейкпоинт-оверрайд, переобъявляющий `font-size` / `padding`, которые уже заданы через `clamp`.
- `@media` по ширине внутри компонента.
- `100vh` на полноэкранных блоках — прыгает под адресной строкой. Только `dvh` / `svh`.
- Сетка по брейкпоинтам «1 → 2 → 3» вместо `auto-fit minmax`.
- Магические px-брейки (520, 768) врассыпную без системы.
- Hover без `(hover: hover)` — на тач-устройстве залипает.
- Фиксированная высота под объём контента — текст обрезается или переполняет.
- Отключённый zoom (`user-scalable=no`, `maximum-scale=1`).
- Шрифт в `px`.
- Физические `left/right` в новом коде.

---

## 9. Проверка

Через агент-поверхность приложения и **ресайз контейнера**, не глаз по скриншоту. Дёргаешь ширину контейнера компонента в обёртке — видишь все его состояния изолированно, без прогона всего вьюпорта.

Обязательный прогон:

- reflow до 320px по ширине без горизонтального скролла (§15);
- zoom 200% — текст не обрезан, ничего не наезжает;
- `prefers-reduced-motion` — анимации сняты;
- `pointer: coarse` — таргеты не мельче нормы;
- состояния из `DESIGN_RULES.md` §7 — на узком и широком контейнере, не только на «десктопе».

---

## 10. Новый компонент — по шагам

1. Сверстать в один поток; значения тянуть из токенов (`var(--t-*)`, `var(--s*)`, `var(--r*)`). Чисел врассыпную нет.
2. Закрыть текучесть `clamp` / `min` / `max`; сетку — `auto-fit minmax`. На этом шаге, как правило, ноль query.
3. Нужна смена структуры — обернуть регион в `@include container(name)`, добавить `@include cq(name, 34rem) { … }`.
4. Hover, таргеты, мошн — миксинами `@media` из §7.
5. Полноэкранное — `dvh` / `svh`; края под вырез — `env(safe-area-inset-*)`.
6. Проверить ресайзом контейнера + reflow 320 / zoom 200, не скриншотом.

---

## 11. Перенос desktop → mobile: по смыслу, не по ширине

Адаптив — переосмысление под контекст, не сжатие десктопа. Узкий экран — другое устройство и другой способ смотреть, а не уменьшенный монитор. Машинально стекать всё в вертикаль и тащить десктоп-эффекты вниз — это не адаптив.

**Горизонтальный ряд по умолчанию НЕ ломать в вертикальный стек.** Если на десктопе блок — это ряд однотипного (галерея, карточки, сравнение), на мобайле чаще правильнее оставить его рядом и дать **горизонтальную прокрутку** (`overflow-x: auto` + `scroll-snap`), а не разворачивать в длинный вертикальный список — так сохраняется смысл «это один ряд».

- **Презентационный ряд** (галерея, логотипы, скриншоты) — **авто-прокрутка** сама + пользователь может листать рукой (свайп перехватывает авто и отдаёт управление). `scroll-snap-type: inline mandatory`.
- **Сравнительный ряд** (тарифы, «что выбрать», сравнение) — **НЕ авто**: листает сам пользователь, элементы остаются **в строку бок о бок**. Сравнение в голове складывается легче, когда варианты рядом и видны вместе, а не разнесены вертикально.
- Всегда — видимый сигнал, что есть продолжение (обрезанный край следующего элемента, точки-индикатор), иначе дальше первого не посмотрят.

**Объёмный функционал сворачивать и приоритизировать.** То, что на десктопе развёрнуто целиком (большое меню, панель фильтров, набор действий, таблица, сайдбар), на мобайле НЕ оставлять как есть — сгруппировать по важности и убрать второстепенное за раскрытие: бургер-меню, выпадающие списки, аккордеоны, «ещё», bottom-sheet, табы. На экране сразу — только главное, остальное в один тап (прогрессивное раскрытие). Мобайл не вмещает десктопный объём, поэтому решают приоритетом и сворачиванием, а не уплотнением всего в один экран.

**Десктопные «фишки» не переносить механически.** Эффект, дающий изюминку на большом экране (сложный hover, параллакс, крупная футуристичная графика, текст-за-объектом), на телефоне часто не читается или мешает — и не выглядит так же круто. Не масштабировать его вниз, а **придумать мобильную замену**: свой приём под маленький тач-экран. Где десктоп-эффект не переносится — на мобайле его нет, а на его месте своё. Это и есть адаптация к ситуации, а не к ширине.

---

Документ: http://docs.gitaspen.ru/development/design/design-atlas

# Атлас областей дизайна интерфейсов

Жанр: карта источников. По этому файлу нельзя выполнить задачу, не обращаясь наружу, — он показывает, какие дисциплины существуют и куда идти за глубиной. Практические правила, по которым делают и ревьюят интерфейс, — в [DESIGN_RULES.md](./DESIGN_RULES.md).

Зачем файл: дать карту областей дизайна, чтобы понимать, какие дисциплины вообще существуют, что каждая решает, где они пересекаются и какие книги и руководства читать глубже.

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

---

## 0. Как читать атлас

Не учить все подряд. Идти слоями:

```text
1. Контекст продукта
2. Человек и восприятие
3. Структура информации
4. Визуальная система
5. Поведение интерфейса
6. Состояния и надежность
7. Доступность и адаптивность
8. Бренд и графика
9. Система, команда, качество
10. Исследования и проверка
```

Если прыгнуть сразу в 3D, Tailwind, glass, brutalism или красивые карточки, база будет слабой. Сначала надо понимать задачу, человека, информацию, действие и состояние.

---

## 1. Что считается областью дизайна, а что нет

Область дизайна отвечает на устойчивый вопрос.

Например:

- цвет отвечает, как кодировать роль, настроение, статус и контраст;
- типографика отвечает, как читать и сканировать текст;
- интеракция отвечает, что происходит после действия;
- информационная архитектура отвечает, где что лежит и как найти;
- дизайн-система отвечает, как не расползтись на 200 экранов.

Не область, а прием/стиль/инструмент:

- glassmorphism;
- neumorphism;
- brutalism;
- Tailwind;
- shadcn;
- card UI;
- 3D hero;
- gradient;
- dark mode;
- "как у Linear".

Они могут быть полезны, но не заменяют дисциплину.

---

## 2. Быстрая карта областей

```text
Продуктовый контекст        -> зачем продукт, кому, какой риск, какая метрика
Сервисный дизайн            -> весь путь человека, не только экран
UX-исследования             -> как узнать, что правда, а не мнение команды
Восприятие и гештальт       -> как глаз группирует и читает экран
Когнитивные законы          -> память, выбор, скорость, моторика
Информационная архитектура  -> структура, навигация, поиск, категории
Контент-дизайн              -> слова, labels, ошибки, пустые состояния
Интеракционный дизайн       -> действие, feedback, states, recovery
Визуальная иерархия         -> что заметят первым, вторым, третьим
Композиция и layout         -> где что стоит и как экран держится
Сетки и spacing             -> ритм, отступы, плотность, порядок
Типографика                 -> читаемость, голос текста, шкалы
Цвет                        -> роли, контраст, семантика, настроение
Форма                       -> радиусы, геометрия, силуэт элементов
Поверхности и глубина       -> слои, elevation, модалки, материалы
Графика                     -> фото, 3D, изометрия, иллюстрации, скриншоты
Иконки и пиктограммы        -> компактный смысл и действия
Data visualization          -> графики, таблицы, dashboards, сравнение
Motion design               -> движение как feedback и связь состояний
Формы и ввод                -> поля, валидация, ошибки, доверие
Navigation/search/filter    -> как ходить, искать, сужать результат
Accessibility               -> доступность как инженерная база
Responsive/mobile/spatial   -> разные экраны, вводы, safe areas, visionOS
Design systems/tokens       -> масштабирование решений
Brand/visual identity       -> характер, настроение, узнаваемость
AI/hybrid/generative UI     -> AI-состояния, контроль, provenance
Product craft/performance   -> скорость, качество, edge cases, долговечность
```

---

## 3. Зависимости

```text
Контекст продукта
  -> Исследования
  -> IA + контент
  -> Интеракция
  -> Визуальная система
  -> Состояния
  -> Доступность + адаптивность
  -> Design system
  -> Бренд/графика/motion
  -> Проверка на людях и метриках
```

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

---

## 4. Главные области

### 4.1. Продуктовый контекст

**Вопрос:** зачем интерфейс существует?

Что определить:

- ЦА;
- контекст использования;
- частота;
- риск действия;
- главный объект;
- главный сценарий;
- бизнес-цель;
- метрика качества;
- desired feeling.

Без этого невозможно выбрать стиль, плотность, цвет, onboarding, уровень доверия и уровень выразительности.

**Главная мысль из книг:** Jesse James Garrett в `The Elements of User Experience` раскладывает UX от стратегии к поверхности: нельзя начинать с pixels, если не ясны user needs и product objectives.

Читать:

- [Книжная карта](./design-books-core-reading-map.md)
- Jesse James Garrett — [The Elements of User Experience](https://www.jjg.net/elements/)
- Jeff Gothelf, Josh Seiden — [Lean UX](https://jeffgothelf.com/books/)

---

### 4.2. Сервисный дизайн

**Вопрос:** что происходит до, во время и после экрана?

Интерфейс часто только видимая часть сервиса. За ним есть поддержка, доставка, документы, операторы, уведомления, возвраты, офлайн-события.

Что проектировать:

- customer journey;
- service blueprint;
- backstage/frontstage;
- handoff между каналами;
- support path;
- failure/recovery;
- ожидание и статусы.

Особенно важно для:

- сервисов мобильности: такси, каршеринг, аренда транспорта;
- банков;
- доставки;
- маркетплейсов;
- госуслуг;
- healthcare;
- B2B operations.

Читать:

- Marc Stickdorn et al. — [This Is Service Design Doing](https://www.thisisservicedesigndoing.com/)

---

### 4.3. UX-исследования и проверка

**Вопрос:** откуда известно, что решение работает?

Методы:

- интервью;
- наблюдение;
- usability test;
- tree testing;
- card sorting;
- survey;
- analytics;
- A/B test;
- heuristic review;
- diary study.

Правило:

```text
Мнение команды не является доказательством.
```

Что выписывать из исследований:

- задачи пользователя;
- язык пользователя;
- ошибки;
- обходные пути;
- критерии доверия;
- моменты тревоги;
- причины выбора/ухода.

Читать:

- Erika Hall — [Just Enough Research](https://www.mulebooks.com/just-enough-research)
- Steve Krug — [Rocket Surgery Made Easy](https://sensible.com/rocket-surgery-made-easy/)
- NN/g — [Usability Testing](https://www.nngroup.com/topic/user-testing/)

---

### 4.4. Восприятие и гештальт

**Вопрос:** как глаз группирует элементы до чтения текста?

Ключевые принципы:

- proximity;
- similarity;
- common region;
- continuity;
- closure;
- figure-ground;
- common fate.

Практическое правило:

```text
Расстояние и общая область сильнее декоративного цвета.
```

Если label стоит ближе к чужому input, никакой красивый стиль не спасет.

Читать:

- NN/g Gestalt articles: [Proximity](https://www.nngroup.com/articles/gestalt-proximity/), [Similarity](https://www.nngroup.com/articles/gestalt-similarity/), [Common Region](https://www.nngroup.com/articles/common-region/)
- William Lidwell et al. — [Universal Principles of Design](https://www.quarto.com/books/9780760375167/universal-principles-of-design-updated-and-expanded-third-edition)
- Susan Weinschenk — `100 Things Every Designer Needs to Know About People`

---

### 4.5. Когнитивные законы и эргономика

**Вопрос:** как память, выбор, моторика и внимание ограничивают интерфейс?

Полезные законы:

- Fitts: важное и частое должно быть крупным и близким;
- Hick: чем больше равных вариантов, тем дольше выбор;
- Jakob: люди ожидают знакомых паттернов;
- Miller: длинные списки лучше группировать;
- Doherty: быстрый отклик удерживает поток;
- Peak-End: люди запоминают пик и конец опыта;
- Zeigarnik: незавершенность держит внимание.

Читать:

- [Laws of UX](https://lawsofux.com/)
- NN/g: [Fitts's Law](https://www.nngroup.com/articles/fitts-law/), [Hick's Law](https://www.nngroup.com/articles/hicks-law/), [Miller's Law](https://www.nngroup.com/articles/millers-law/)

---

### 4.6. Информационная архитектура

**Вопрос:** где что лежит, как называется и как найти?

IA включает:

- organization systems;
- labeling;
- navigation;
- search;
- metadata;
- taxonomy;
- wayfinding.

Типичные ошибки:

- search как костыль плохой структуры;
- одинаковые сущности названы разными словами;
- меню отражает структуру компании, а не модель пользователя;
- фильтры и категории смешаны.

Читать:

- Rosenfeld, Morville, Arango — [Information Architecture: For the Web and Beyond](https://www.oreilly.com/library/view/information-architecture-4th/9781491913529/)
- Peter Morville — `Ambient Findability`

---

### 4.7. Контент-дизайн и UX writing

**Вопрос:** помогают ли слова выполнить задачу?

Контент-дизайн отвечает за:

- headings;
- labels;
- CTA;
- error messages;
- empty states;
- help text;
- tone;
- terminology;
- plain language.

Правило:

```text
Кнопка должна говорить, что произойдет.
Ошибка должна говорить, как исправить.
Пустое состояние должно говорить, что дальше.
```

Читать:

- Sarah Richards / Content Design London — [Content Design](https://contentdesign.london/shop/content-design-by-sarah-winters-and-rachel-edwards)
- Torrey Podmajersky — [Strategic Writing for UX](https://www.oreilly.com/library/view/strategic-writing-for/9781492049388/)
- GOV.UK — [Content Design](https://www.gov.uk/guidance/content-design)

---

### 4.8. Интеракционный дизайн

**Вопрос:** что происходит после действия пользователя?

Цепочка:

```text
intent -> affordance -> action -> feedback -> state -> recovery
```

Интеракция включает:

- affordances;
- pressed/hover/focus states;
- loading;
- validation;
- undo;
- confirmation;
- optimistic UI;
- sync/offline/conflict;
- permissions;
- error recovery.

Читать:

- Alan Cooper et al. — [About Face](https://www.wiley.com/en-us/About%2BFace%3A%2BThe%2BEssentials%2Bof%2BInteraction%2BDesign%2C%2B4th%2BEdition-p-9781118766576)
- Jenifer Tidwell et al. — [Designing Interfaces](https://www.oreilly.com/library/view/designing-interfaces-3rd/9781492051954/)
- Don Norman — [The Design of Everyday Things](https://www.basicbooks.com/titles/don-norman/the-design-of-everyday-things/9780465050659/)

---

### 4.9. Визуальная иерархия

**Вопрос:** что человек заметит первым?

Инструменты:

- размер;
- вес;
- цвет;
- контраст;
- позиция;
- whitespace;
- grouping;
- motion;
- elevation.

Правило:

```text
Если выделено все, не выделено ничего.
```

Проверки:

- blur/squint test;
- grayscale test;
- 3-second test;
- one primary action.

Читать:

- NN/g — [Visual Hierarchy](https://www.nngroup.com/articles/visual-hierarchy-ux-definition/)
- Adam Wathan, Steve Schoger — [Refactoring UI](https://refactoringui.com/)

---

### 4.10. Композиция и layout

**Вопрос:** как экран держится целиком?

Композиция отвечает за:

- точку входа;
- баланс;
- alignment;
- зоны;
- scan path;
- whitespace;
- density;
- relationship between blocks.

Правило:

```text
Экран должен читаться до чтения текста.
```

Читать:

- Josef Müller-Brockmann — [Grid Systems in Graphic Design](https://draw-down.com/products/grid-systems-in-graphic-design)
- Ellen Lupton — [Thinking with Type](https://papress.com/products/thinking-with-type-3-edition)

---

### 4.11. Сетки и spacing

**Вопрос:** как сделать расстояния системными, а не случайными?

Spacing выражает смысл:

```text
внутри элемента < внутри группы < между группами < между секциями
```

Что фиксировать:

- base unit;
- spacing scale;
- gutters;
- container widths;
- density modes;
- component padding;
- responsive behavior.

Читать:

- Material — [Layout](https://m3.material.io/foundations/layout/overview)
- Carbon — [Spacing](https://carbondesignsystem.com/elements/spacing/overview/)

---

### 4.12. Типографика

**Вопрос:** как текст читается, сканируется и звучит?

Типографика включает:

- font choice;
- type scale;
- roles;
- line-height;
- measure;
- weight;
- alignment;
- numeric typography;
- localization.

Главное:

```text
Интерфейс читают, а не рассматривают.
```

Читать:

- Ellen Lupton — [Thinking with Type](https://papress.com/products/thinking-with-type-3-edition)
- Material — [Typography](https://m3.material.io/styles/typography/overview)
- Apple — [Typography](https://developer.apple.com/design/human-interface-guidelines/typography)

---

### 4.13. Цвет

**Вопрос:** что цвет кодирует: роль, статус, настроение или бренд?

Цвет должен иметь роли:

- background;
- surface;
- text;
- muted text;
- border;
- primary;
- success;
- warning;
- danger;
- focus;
- selection.

60/30/10 полезно как sanity check, но важнее semantic color system и WCAG contrast.

Читать:

- Material — [Color roles](https://m3.material.io/styles/color/roles)
- Apple — [Color](https://developer.apple.com/design/human-interface-guidelines/color)
- WCAG — [Contrast Minimum](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)

---

### 4.14. Форма

**Вопрос:** какую геометрию имеют элементы и что она сообщает?

Форма включает:

- radius scale;
- pills;
- squircles;
- icon shape language;
- component silhouette;
- concentric radius;
- touch shape.

Форма влияет на настроение:

- sharp = technical, strict, editorial;
- rounded = friendly, consumer, soft;
- pill = compact action/filter;
- squircle = premium/mobile/native.

Читать:

- Material — [Shape](https://m3.material.io/styles/shape/overview)
- Apple — [Materials](https://developer.apple.com/design/human-interface-guidelines/materials)

---

### 4.15. Поверхности, глубина, материалы

**Вопрос:** что над чем лежит и почему?

Сюда входят:

- surface;
- elevation;
- shadow;
- scrim;
- modal layer;
- popover;
- sheet;
- glass/material;
- z-index logic.

Правило:

```text
Глубина должна объяснять слой и состояние, а не украшать.
```

Читать:

- Apple — [Materials](https://developer.apple.com/design/human-interface-guidelines/materials)
- Material — [Elevation](https://m3.material.io/styles/elevation/overview)

---

### 4.16. Графика и visual assets

**Вопрос:** какую работу выполняет картинка?

Типы:

- photo;
- 2D illustration;
- 3D render;
- isometry;
- product screenshot;
- device mockup;
- pictogram;
- diagram;
- motion graphic.

Правило:

```text
3D для объекта и ощущения.
Изометрия для системы и связи.
Фото для реальности.
Скриншот для доказательства.
Пиктограмма для быстрого смысла.
```

Читать:

- NN/g — [Using Imagery in Visual Design](https://www.nngroup.com/articles/imagery-in-visual-design/)
- Microsoft — [Fluent Illustrations](https://microsoft.design/articles/embracing-vibrant-universality-in-fluent-illustrations/)
- Atlassian — [Illustrations](https://atlassian.design/foundations/illustrations)

---

### 4.17. Иконки и пиктограммы

**Вопрос:** можно ли понять действие/категорию с первого взгляда?

Разделение:

- icon = действие/навигация/status;
- pictogram = крупная идея/категория/объяснение.

Правила:

- unfamiliar icon needs label;
- icon-only action needs tooltip/fallback;
- one icon pack;
- consistent stroke/fill/grid;
- не делать некликабельное похожим на кнопку.

Читать:

- IBM Carbon — [Pictograms](https://carbondesignsystem.com/elements/pictograms/usage/)
- Apple — [Icons](https://developer.apple.com/design/human-interface-guidelines/icons)
- Material — [Icons](https://m3.material.io/styles/icons/overview)

---

### 4.18. Data visualization и dashboards

**Вопрос:** какое решение помогает принять график?

Data-viz отвечает за:

- chart type;
- comparison;
- trend;
- anomaly;
- baseline;
- color encoding;
- annotations;
- dashboard density;
- table/chart balance.

Правило:

```text
Dashboard не кладбище графиков, а инструмент решения.
```

Читать:

- Edward Tufte — [The Visual Display of Quantitative Information](https://www.edwardtufte.com/book/the-visual-display-of-quantitative-information/)
- Cole Nussbaumer Knaflic — [Storytelling with Data](https://www.storytellingwithdata.com/books)
- Stephen Few — [Information Dashboard Design](https://www.perceptualedge.com/library.php)

---

### 4.19. Motion design

**Вопрос:** как состояния связываются во времени?

Motion нужен для:

- feedback;
- continuity;
- hierarchy;
- spatial relationship;
- loading;
- direct manipulation;
- delight.

Пропорция:

```text
80% functional feedback
15% transitions
5% delight
```

Читать:

- Apple — [Motion](https://developer.apple.com/design/human-interface-guidelines/motion)
- Material — [Motion](https://m3.material.io/styles/motion/overview)
- Val Head — `Designing Interface Animation`
- Rachel Nabors — `Animation at Work`

---

### 4.20. Формы и ввод

**Вопрос:** как человек безопасно вводит данные?

Сюда входят:

- labels;
- helper text;
- masks;
- validation;
- error summary;
- keyboard type;
- progress in long forms;
- save/recovery;
- trust signals.

Правило:

```text
Форма - это разговор, а не список полей.
```

Читать:

- GOV.UK — [Form structure](https://www.gov.uk/service-manual/design/form-structure)
- GOV.UK — [Error message](https://design-system.service.gov.uk/components/error-message/)

---

### 4.21. Navigation, search, filters

**Вопрос:** как человек перемещается, ищет и сужает выбор?

Разделять:

- navigation = куда идти;
- search = найти известное/примерное;
- filters = сузить набор;
- sort = поменять порядок;
- tabs = переключить views;
- chips = показать активные ограничения.

Читать:

- Apple WWDC26 — [Design intuitive search experiences](https://developer.apple.com/videos/play/wwdc2026/292/)
- Rosenfeld et al. — [Information Architecture](https://www.oreilly.com/library/view/information-architecture-4th/9781491913529/)

---

### 4.22. Accessibility и inclusive design

**Вопрос:** работает ли интерфейс для людей с разными возможностями и условиями?

База:

- semantic markup;
- keyboard;
- focus;
- contrast;
- target size;
- screen reader labels;
- captions/transcripts;
- reduced motion;
- error identification;
- reflow/zoom.

Правило:

```text
Accessibility не чеклист в конце, а ограничение дизайна с первого решения.
```

Читать:

- WCAG — [Guidelines](https://www.w3.org/WAI/WCAG22/quickref/)
- Heydon Pickering — [Inclusive Components](https://book.inclusive-components.design/)
- Sarah Horton, Whitney Quesenbery — [A Web for Everyone](https://books.apple.com/us/book/a-web-for-everyone/id1278370288)

---

### 4.23. Responsive, mobile, spatial

**Вопрос:** как интерфейс живет на разных экранах, вводах и средах?

Сюда входят:

- mobile-first;
- breakpoints by content;
- container queries;
- safe areas;
- reachability;
- gestures;
- bottom sheets;
- live activities;
- foldable/large screens;
- visionOS/spatial layout.

Читать:

- Apple — [Designing for iOS](https://developer.apple.com/design/human-interface-guidelines/designing-for-ios)
- Apple — [Spatial Layout](https://developer.apple.com/design/human-interface-guidelines/spatial-layout)
- Ethan Marcotte — `Responsive Web Design`

---

### 4.24. Design systems и tokens

**Вопрос:** как сделать так, чтобы качество не развалилось при росте продукта?

Система включает:

- principles;
- tokens;
- components;
- states;
- patterns;
- documentation;
- governance;
- Figma/code sync;
- accessibility rules;
- examples/anti-examples.

Правило:

```text
Design system - это не UI-kit, а способ принимать одинаковые решения.
```

Читать:

- Alla Kholmatova — [Design Systems](https://www.smashingmagazine.com/printed-books/design-systems/)
- Brad Frost — [Atomic Design](https://atomicdesign.bradfrost.com/)
- Figma — [Design systems in AI era](https://www.figma.com/blog/5-shifts-redefining-design-systems-in-the-ai-era/)

---

### 4.25. Brand и visual identity

**Вопрос:** какой характер продукта и где он должен проявляться?

Brand in UI включает:

- audience;
- positioning;
- tone;
- color mood;
- typography;
- shape;
- imagery;
- motion;
- density;
- trust level;
- moments of delight.

Правило:

```text
Бренд должен усиливать сценарий, а не ломать platform familiarity.
```

Читать:

- Apple WWDC26 — [Communicate your brand identity on iOS](https://developer.apple.com/videos/play/wwdc2026/251/)
- Apple — [Branding](https://developer.apple.com/design/human-interface-guidelines/branding)

---

### 4.26. AI / hybrid / generative UI

**Вопрос:** как дать AI-помощь без потери контроля?

AI UI требует:

- capability intro;
- prompt/input;
- controls;
- generating state;
- tool call state;
- sources/provenance;
- draft/applied separation;
- edit/reject/regenerate;
- undo;
- uncertainty;
- privacy note.

Правило:

```text
AI output должен быть workflow, а не просто красивый ответ.
```

Читать:

- Microsoft — [HAX Guidelines](https://www.microsoft.com/en-us/haxtoolkit/ai-guidelines/)
- Google — [PAIR Guidebook](https://pair.withgoogle.com/guidebook-v2/)
- IBM — [Carbon for AI](https://carbondesignsystem.com/guidelines/carbon-for-ai/)
- Google Research — [Generative UI](https://research.google/blog/generative-ui-a-rich-custom-visual-interactive-user-experience-for-any-prompt/)

---

### 4.27. Product craft, performance, quality

**Вопрос:** почему продукт ощущается дорогим и живым?

Craft включает:

- speed;
- responsiveness;
- keyboard flow;
- edge cases;
- empty/error/loading states;
- naming;
- alignment;
- consistency;
- animation restraint;
- real data;
- no design debt.

Linear важен не темной эстетикой, а дисциплиной качества. Todoist важен не минимализмом, а скоростью capture и отсутствием сопротивления.

Читать:

- Linear — [Why is quality so rare?](https://linear.app/now/why-is-quality-so-rare)
- Figma — [Karri Saarinen's 10 rules](https://www.figma.com/blog/karri-saarinens-10-rules-for-crafting-products-that-stand-out/)
- Doist — [Design and development workflow](https://www.todoist.com/inspiration/design-development-workflow)

---

## 5. Что не входит в карту как отдельная область

В карте нет как самостоятельных областей:

- `Tailwind/shadcn` - инструменты и заготовки, а не дизайн-дисциплина.
- `Glassmorphism/neumorphism/brutalism` - стилистические приемы; выбор стиля описан в разделе 13 [DESIGN_RULES.md](./DESIGN_RULES.md).
- `3D/isometry` как "модный стиль" - разбирается в области графики (4.16), где важна работа ассета.
- Разрозненные подборки из соцсетей - сырье для вдохновения, а не источник правил.
- Большой глоссарий терминов - справочный материал другого жанра, карту областей он раздувает.

---

## 6. Главные связки

### Экран

```text
IA + hierarchy + spacing + typography + actions + states + accessibility
```

### Кнопка

```text
action hierarchy + label + color role + shape + target size + states + focus
```

### Форма

```text
IA + content + input behavior + validation + error recovery + trust
```

### Dashboard

```text
data question + chart/table choice + density + comparison + alerts + drill-down
```

### Landing

```text
positioning + brand + product proof + imagery + CTA + trust
```

### AI feature

```text
user goal + input + model state + output as draft + provenance + apply/undo
```

---

## 7. Порядок изучения

### Первый слой

1. Восприятие и гештальт.
2. Визуальная иерархия.
3. Spacing/layout.
4. Типографика.
5. Цвет.

### Второй слой

6. IA.
7. Контент-дизайн.
8. Интеракция.
9. Формы/states.
10. Accessibility.

### Третий слой

11. Responsive/mobile.
12. Motion.
13. Design systems.
14. Графика/иконки/data-viz.
15. Бренд/style.

### Четвертый слой

16. UX research.
17. Product craft.
18. AI/generative UI.
19. Service design.
20. Реальные продукты и разборы.

---

## 8. Какие вопросы задавать по любой области

```text
Что это решает?
Что ломается без этого?
Какие правила у области?
Какие анти-паттерны?
Как это проявляется в живом продукте?
Как проверить?
Как зафиксировать в дизайн-системе?
Какие книги/источники дают фундамент?
```

---

## 9. Главный вывод

Хороший интерфейс появляется не из одного "стиля", а из согласованности дисциплин:

```text
контекст -> структура -> поведение -> визуальная система -> состояния -> бренд -> проверка
```

Слабый интерфейс обычно делает наоборот:

```text
стиль -> карточки -> цвета -> случайные кнопки -> потом пытаемся понять сценарий
```

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

---

Документ: http://docs.gitaspen.ru/development/design/visual-craft-premium

# Визуальное ремесло: как делать «дорого» и современно

Источник: разбор трёх Instagram-профилей через визуальный анализ ~30 работ — [@orbixstudiollc](https://www.instagram.com/orbixstudiollc) (сайты/лендинги), [@orbixdashboard](https://www.instagram.com/orbixdashboard) (дашборды), [@webdesignssphere](https://www.instagram.com/webdesignssphere) (веб-дизайн).

**Зачем этот файл.** Остальная база сильна в UX-механике (структура, действия, состояния, доступность — «не сломано и понятно»). Но «премиум-вид», «вау-но-удобно», современность — это ОТДЕЛЬНЫЙ слой ремесла, которого в базе не было. Без него выходит корректно, но пресно и «старо». Здесь — этот слой.

## Главный вывод
Премиум — это НЕ «больше эффектов». Это: ограничить палитру + огромный контраст масштаба типографики + отдать сцену качественному ассету + щедрый воздух + **глубину светом, а не границами** + слойность по Z. Формула всех трёх профилей: **сдержанность + глубина**. «Дорого» возникает на уровне ПОДАЧИ, не только макета.

---

## 1. Глубина светом, а не границами ★ (главное, чего не хватало)
- **Мягкие диффузные тени:** большой blur, низкая opacity — не «drop-shadow», а «свечение»; карточки парят. Цветные тени (тинт бренда / тёмно-синий), не чёрные. Многослойные, напр. `0 10px 30px rgba(0,0,0,.05)`.
- **1px rim-light кромка** низкой opacity (`rgba(255,255,255,.1)` на тёмном; чуть темнее/светлее фона на светлом) — определяет край без тяжёлой рамки.
- **Ambient / aura glow** — большие мягкие радиальные градиенты бренд-цвета ЗА элементами (за hero, за активной карточкой). Интерфейс «излучает свет».
- **Mesh-градиенты на фонах** — убирают «мёртвую плоскость» пустого пространства.
- **Subtle-градиенты на плоских цветах** (хедер темнее→светлее, CTA) — спасают от «дёшево/плоско».
- **Glassmorphism точечно** — `backdrop-blur 20–30px` на доках/попапах/оверлеях, фон просвечивает.
- **Film grain / noise** тонким слоем — убирает «пластик» цифры, даёт киношность (фикс. `pointer-events:none`).

## 2. Z-слойность и «ломка контейнера»
- Три плана: гигантский фоновый вотермарк-бренд (низкая opacity) → фото/мокап (средний план) → резкий UI поверх.
- **Герой-объект вылезает за границы** карточки/секции (вырезанный персонаж/продукт залезает на соседний блок) — ломает «коробочность», даёт объём.
- Элементы наезжают друг на друга (карточка-статистика плавает поверх фото).
- Вырезанный субъект (masked cutout) поверх паттернов/градиентов — bespoke, не сток.

## 3. Типографика: контраст масштаба + editorial
- **Экстремальный контраст:** H1 в 4–5× больше body; дисплейный до ~120px при body 14–16px. Заголовок = графический элемент, не «информация».
- Геометрический гротеск (Inter / Satoshi / Geist / Plus Jakarta / SF Pro), высокий x-height. НЕ системный дефолт.
- Плотный **отрицательный трекинг** (−1…−2%) + сжатый line-height на крупных — текст как единый «блок-объект». (В люксе/фэшн — наоборот, РАСширенный трекинг.)
- Пара гротеск + контрастный serif/курсив для «фэшн-хаус» акцента.
- **«Значение vs подпись»:** число жирное тёмное, подпись мельче/светлее/серая (60–70% opacity).
- Микро-лейблы капсом с широким трекингом (`• FEATURES`) — указатели секций.
- Семантическая подсветка фразы (ключевое слово в бренд-цвет или pill-плашку).
- Числа — `tabular-nums`.

## 4. Цвет
- **Никогда #FFF / #000.** Off-white (`#F8F9FB`, тёплый/холодный) и charcoal/navy/eggplant (`#0D1117`, `#0A0118`) — под них живут тени и glow; на чистом чёрном они умирают.
- **Один высокохромный акцент дозированно (<5%):** electric violet / blue / lime / orange. Насыщенность — только интерактиву и данным; UI-мебель (рамки, фоны) обесцвечена.
- **Монохромный ДИАПАЗОН одного цвета** (forest → lime, charcoal → vibrant) вместо одного плоского тона.
- **Отстройка от клише ниши** ★ — финтех фиолетовый, не «зелёная стрелка»; gym navy+violet, не «чёрный+оранж»; медицина electric-blue, не «больничный sterile»; агентство forest-green, не tech-blue.
- Палитра **вытянута из герой-фото**; лого-партнёров десатурировать в единый серый.

## 5. Композиция / layout
- **Асимметричный hero** (не центр, не лобовой «текст слева / фото справа»): тяжёлый объект справа уравновешен крупной типографикой слева; текст расставлен ВОКРУГ объекта.
- **Bento с асимметрией И глубиной** (не плоские плитки!): карточки разной ширины со слоями/тенями/ломкой границ. Это не «бенто-как-доминанта», а bento + Z-глубина.
- **Вложенный радиус концентрично** (карточка круглее кнопок внутри — Apple-логика).
- Чередование ритма (checkerboard image-left↔right) в длинных страницах.
- **Контрастные разрывы секций** (тёмная → светлая → тонированная) — раскадровка, борьба со скролл-усталостью.
- Full-bleed изображения «как киноплёнка». Вертикальный повёрнутый текст на полях (журнальный якорь).

## 6. Пространство как роскошь
- Макро-whitespace: 150px+ между секциями, padding карточек 24–32px. «Расточительность к пространству = уверенность бренда».
- Негативное пространство как фокус: один объект в море пустоты = эксклюзивность.

## 7. Качество ассетов = половина результата ★
- Высокофидельный 3D (рендеры, персонажи с subsurface scattering), **арт-дирекшн фото под палитру** (малая ГРИП, golden-hour, full-bleed), кастомные мокапы под цвет фона с наклоном.
- НЕ сток, НЕ серый picsum. «Premium на 50% — это дизайн и на 50% — качество ассетов». Фото и UI неразделимы: цвета одежды/фона совпадают с палитрой UI.
- Hand-drawn гуманизация (scribble-иконки, botanical) — точечно.

## 8. Радиусы / форма
- 16–32px squircle везде (до 40–100px на крупных блоках), **концентрично вложенные**. Pill (полное скругление) для тегов/кнопок. Острые 4–8px = маркер устаревшего шаблона.
- Единый «curvature ratio» по всем элементам — «hardware-like» цельность.

## 9. Дата-виз как арт (для дашбордов)
- Area-chart с вертикальным **градиент-fill** (тающим в прозрачность снизу); bezier-кривые (spline) с glow-обводкой, не ломаные.
- Кастомные столбцы: скруглённые верхушки + вертикальный градиент + glow на активном.
- **Штриховка/hatching** под линией вместо сплошной заливки — «выглядит как кастом-код, не дефолтный Chart.js».
- Donut/segmented rings, sparkline в углах KPI, % изменения в пилюле семантическим цветом, floating-тултипы с тенью.
- Срезать оси/тики/сетку — «тренд важнее точки».

## 10. Motion (осмысленный)
- Стаггеринг при загрузке (значения 0→target = «живые данные»); easing-аккордеоны; cross-dissolve + лёгкий слайд между табами (сохраняет ориентацию); ghost-кнопки реагируют на курсор; glow на активном. Ничего не «появляется» резко.

---

## Чеклист «не пресно» (прогон перед сдачей экрана)
- [ ] Глубина светом (диффузные тени / aura-glow / mesh / 1px rim-кромка), а не плоские border-коробки.
- [ ] Хотя бы один Z-слой / ломка границы / крупный фоновый вотермарк.
- [ ] Дисплейный заголовок в 3–5× body (контраст масштаба).
- [ ] Off-white / charcoal, не #FFF/#000; один акцент <5%; отстройка от клише ниши.
- [ ] Качественный ассет-герой (3D или фото-арт-дирекшн под палитру), не сток/picsum.
- [ ] Радиусы 16–32 squircle, концентрично; единый curvature.
- [ ] Whitespace щедрый (секции 100px+).

## Связь с остальной базой
Документ не отменяет правила из [DESIGN_RULES.md](./DESIGN_RULES.md) (раздел 13.1 отсылает сюда), а
дополняет их визуальным слоем. Три места, где он с ними стыкуется:

- **Карточки.** Запрет из раздела 18 правил (`card soup`) относится к плоским одинаковым плиткам как
  основе композиции. Приём остаётся рабочим при разной глубине, слоях и намеренном нарушении границ.
- **Вложенность.** Блок в блоке не добавляется: глубина даётся светом, а не рамкой внутри рамки.
- **Ясность важнее эффекта.** Приёмы выше дают ощущение дорогой работы, не добавляя трения — свет и
  воздух ведут взгляд, а не мешают.

Оговорка о происхождении чисел: точные значения цвета и названия шрифтов получены разбором
изображений и не подтверждены исходниками. Опираться стоит на приёмы, а не на конкретные значения;
названия брендов из разборов не проверялись.

---

Документ: http://docs.gitaspen.ru/development/design/design-books-core-reading-map

# Книжная карта по дизайну интерфейсов

Жанр: карта источников. Файл не заменяет чтение и не пересказывает книги целиком — по нему нельзя выполнить задачу, не обращаясь наружу.

Зачем файл: связать области дизайна с книгами и фундаментальными источниками. Это не список “почитать когда-нибудь”, а карта: какую книгу зачем открывать и какие идеи из нее забрать в практику. Ключевые идеи выписаны на уровне конспекта, с указанием, где книга особенно полезна.

---

## 1. Как пользоваться

Читать не линейно, а по вопросу:

```text
Не понимаю, почему UI неудобный       -> Norman, Krug, NN/g
Нужно проектировать сценарии          -> About Face, Designing Interfaces
Нужно разложить структуру продукта    -> Information Architecture, Elements of UX
Нужны основы визуала                  -> Universal Principles, Thinking with Type, Grid Systems, Refactoring UI
Нужны формы/слова/ошибки              -> Content Design, Strategic Writing for UX, GOV.UK
Нужна система на продукт              -> Design Systems, Atomic Design
Нужна доступность                     -> Inclusive Components, A Web for Everyone, WCAG
Нужны графики/dashboard               -> Tufte, Storytelling with Data, Stephen Few
Нужно проверить идею                  -> Just Enough Research, Sprint, Lean UX
Нужен сервис целиком                  -> This Is Service Design Doing
```

---

## 2. Фундамент: человек, восприятие, usability

### Don Norman — The Design of Everyday Things

Источник: [Basic Books](https://www.basicbooks.com/titles/don-norman/the-design-of-everyday-things/9780465050659/)

Что дает:

- дизайн как communication между объектом и человеком;
- affordances/signifiers;
- feedback;
- constraints;
- mapping;
- conceptual model;
- error as design failure, not user stupidity.

Что забрать:

```text
Пользователь не должен угадывать, что делать.
Объект должен сам подсказывать действие.
Ошибка часто говорит о плохой модели интерфейса.
```

Куда в атлас: восприятие, интеракция, аффордансы, ошибки.

### Steve Krug — Don't Make Me Think

Источник: [sensible.com](https://sensible.com/dont-make-me-think/)

Что дает:

- web usability простым языком;
- люди не читают страницы, а сканируют;
- очевидность важнее оригинальности;
- navigation должна быть самообъяснимой;
- usability testing можно делать дешево.

Что забрать:

```text
Если человеку надо думать, что это за элемент и куда он ведет, UI уже проигрывает.
```

Куда в атлас: визуальная иерархия, navigation, usability, content.

### William Lidwell, Kritina Holden, Jill Butler — Universal Principles of Design

Источник: [Quarto](https://www.quarto.com/books/9780760375167/universal-principles-of-design-updated-and-expanded-third-edition)

Что дает:

- энциклопедию законов и паттернов;
- 80/20, chunking, hierarchy, affordance, consistency, framing, signal-to-noise;
- общий язык между UX, visual, product, cognitive psychology.

Что забрать:

```text
Дизайн — набор повторяющихся законов поведения и восприятия, а не вкусовые решения.
```

Куда в атлас: все фундаментальные области.

### Susan Weinschenk — 100 Things Every Designer Needs to Know About People

Источник: [Pearson listing](https://www.pearson.com/en-us/subject-catalog/p/100-things-every-designer-needs-to-know-about-people/P200000003215)

Что дает:

- поведение, зрение, внимание, память, мотивация;
- полезно для понимания, почему proximity, chunking, progressive disclosure работают.

Что забрать:

```text
Интерфейс должен учитывать ограничения внимания и памяти, а не ожидать рационального идеального пользователя.
```

Куда в атлас: когнитивные законы, research, hierarchy.

---

## 3. Interaction design и UI patterns

### Alan Cooper et al. — About Face

Источник: [Wiley](https://www.wiley.com/en-us/About%2BFace%3A%2BThe%2BEssentials%2Bof%2BInteraction%2BDesign%2C%2B4th%2BEdition-p-9781118766576)

Что дает:

- goal-directed design;
- personas as goals, not decorative profiles;
- interaction patterns;
- scenarios;
- conceptual models;
- mobile/touch considerations.

Что забрать:

```text
Проектировать надо не экран, а достижение цели человеком в контексте.
```

Куда в атлас: продуктовый контекст, интеракция, сценарии.

### Jenifer Tidwell, Charles Brewer, Aynne Valencia — Designing Interfaces

Источник: [O'Reilly](https://www.oreilly.com/library/view/designing-interfaces-3rd/9781492051954/)

Что дает:

- каталог UI-паттернов для web/mobile/desktop;
- практические варианты решения типовых задач;
- idea sourcebook для компонентов и взаимодействий.

Что забрать:

```text
Почти любая UI-задача уже имеет несколько зрелых паттернов. Сначала понять паттерн, потом стилизовать.
```

Куда в атлас: component patterns, navigation, forms, lists, search.

### Steve Krug — Rocket Surgery Made Easy

Источник: [sensible.com](https://sensible.com/rocket-surgery-made-easy/)

Что дает:

- практику быстрых usability tests;
- как находить и чинить проблемы без огромной исследовательской машины.

Что забрать:

```text
Лучше регулярно тестировать маленькими порциями, чем спорить месяцами.
```

Куда в атлас: UX research, usability.

---

## 4. Структура продукта, IA, стратегия

### Jesse James Garrett — The Elements of User Experience

Источник: [jjg.net](https://www.jjg.net/elements/)

Что дает:

- пять слоев UX: strategy, scope, structure, skeleton, surface;
- связь business/user needs с визуальным результатом;
- объяснение, почему UI нельзя начинать с surface.

Что забрать:

```text
Surface — последний слой. Если strategy/scope/structure мутные, красивый surface не спасет.
```

Куда в атлас: продуктовый контекст, IA, visual system.

### Rosenfeld, Morville, Arango — Information Architecture: For the Web and Beyond

Источник: [O'Reilly](https://www.oreilly.com/library/view/information-architecture-4th/9781491913529/)

Что дает:

- organization, labeling, navigation, search, metadata;
- IA как фундамент находимости и понимания;
- методы от исследования до реализации.

Что забрать:

```text
Навигация, поиск и названия — это не подписи в конце, а архитектура продукта.
```

Куда в атлас: IA, navigation, search, content.

### Marc Stickdorn et al. — This Is Service Design Doing

Источник: [thisisservicedesigndoing.com](https://www.thisisservicedesigndoing.com/)

Что дает:

- service design methods;
- workshops;
- journey maps;
- service blueprint;
- implementation thinking.

Что забрать:

```text
Экран — только frontstage. Пользовательский опыт часто ломается backstage-процессом.
```

Куда в атлас: service design, mobility, finance, support, operations.

---

## 5. Исследования, discovery, проверка

### Erika Hall — Just Enough Research

Источник: [Mule Books](https://www.mulebooks.com/just-enough-research)

Что дает:

- research как умение задавать хорошие вопросы;
- снижение неизвестности;
- критическое мышление;
- практичные методы для маленьких команд.

Что забрать:

```text
Research нужен не для отчета, а чтобы команда перестала строить продукт на догадках.
```

Куда в атлас: UX research, product context.

### Jake Knapp et al. — Sprint

Источники: [Jake Knapp](https://jakeknapp.com/sprint), [GV Sprint](https://www.gv.com/sprint/)

Что дает:

- пятидневный процесс: problem -> prototype -> user test;
- быстрый способ проверить рискованную идею до разработки.

Что забрать:

```text
Прототип нужен не чтобы выглядеть готовым, а чтобы получить реакцию до больших затрат.
```

Куда в атлас: discovery, prototyping, validation.

### Jeff Gothelf, Josh Seiden — Lean UX

Источник: [Jeff Gothelf books](https://jeffgothelf.com/books/)

Что дает:

- hypothesis-driven design;
- cross-functional collaboration;
- learning over deliverables;
- de-risking product decisions.

Что забрать:

```text
Ценность дизайна не в макете, а в проверенном изменении поведения/результата.
```

Куда в атлас: research, product craft, team process.

---

## 6. Визуальный фундамент

### Ellen Lupton — Thinking with Type

Источник: [Princeton Architectural Press](https://papress.com/products/thinking-with-type-3-edition)

Что дает:

- шрифты, иерархия, строки, интервалы;
- type as system;
- как буквы, слова и абзацы становятся интерфейсом.

Что забрать:

```text
Типографика — это не выбор красивого шрифта, а управление чтением.
```

Куда в атлас: typography, hierarchy, content.

### Josef Müller-Brockmann — Grid Systems in Graphic Design

Источник: [Draw Down Books](https://draw-down.com/products/grid-systems-in-graphic-design)

Что дает:

- сетка как инструмент порядка;
- модульность;
- alignment;
- композиционная дисциплина.

Что забрать:

```text
Сетка не делает дизайн автоматически хорошим, но защищает от случайности.
```

Куда в атлас: composition, layout, spacing.

### Adam Wathan, Steve Schoger — Refactoring UI

Источник: [Refactoring UI](https://refactoringui.com/)

Что дает:

- practical UI for developers;
- hierarchy, spacing, color, shadows, typography;
- как улучшать интерфейс конкретными приемами.

Что забрать:

```text
Дизайн можно улучшать как код: маленькими осознанными рефакторингами.
```

Куда в атлас: visual hierarchy, spacing, color, developer-facing UI.

---

## 7. Контент и UX writing

### Sarah Richards / Content Design London — Content Design

Источник: [Content Design London](https://contentdesign.london/shop/content-design-by-sarah-winters-and-rachel-edwards)

Что дает:

- content as design;
- user needs;
- structure;
- plain language;
- journey mapping in content.

Что забрать:

```text
Писать нужно не то, что компания хочет сказать, а то, что помогает пользователю сделать задачу.
```

Куда в атлас: content design, service design, forms.

### Torrey Podmajersky — Strategic Writing for UX

Источник: [O'Reilly](https://www.oreilly.com/library/view/strategic-writing-for/9781492049388/)

Что дает:

- UX voice strategy;
- UI text patterns;
- words tied to engagement, conversion, retention;
- writing as product behavior.

Что забрать:

```text
Слова в UI — это управляющие элементы, а не подписи после дизайна.
```

Куда в атлас: UX writing, errors, onboarding, AI UI.

---

## 8. Дизайн-системы и компоненты

### Alla Kholmatova — Design Systems

Источник: [Smashing Magazine](https://www.smashingmagazine.com/printed-books/design-systems/)

Что дает:

- shared design language;
- patterns, principles, documentation;
- how systems empower teams;
- система как культура, не только библиотека.

Что забрать:

```text
Дизайн-система — это язык решений. Без языка команда производит разнобой.
```

Куда в атлас: design systems, governance, tokens.

### Brad Frost — Atomic Design

Источник: [atomicdesign.bradfrost.com](https://atomicdesign.bradfrost.com/)

Что дает:

- atoms/molecules/organisms/templates/pages;
- thinking in systems, not pages;
- pattern libraries.

Что забрать:

```text
Компоненты должны проверяться в реальном контексте, а не жить как музей отдельных деталей.
```

Куда в атлас: components, design systems, frontend.

---

## 9. Accessibility и inclusive design

### Heydon Pickering — Inclusive Components

Источник: [Inclusive Components](https://book.inclusive-components.design/)

Что дает:

- common components through accessibility lens;
- robust patterns;
- how vulnerable users experience UI.

Что забрать:

```text
Компонент считается готовым только когда готово его доступное поведение.
```

Куда в атлас: accessibility, components, forms, navigation.

### Sarah Horton, Whitney Quesenbery — A Web for Everyone

Источник: [Apple Books listing](https://books.apple.com/us/book/a-web-for-everyone/id1278370288)

Что дает:

- accessibility through people and universal design;
- practical advice without sacrificing design quality.

Что забрать:

```text
Доступность — это не “для небольшой группы”, а качество интерфейса в разных условиях.
```

Куда в атлас: accessibility, inclusive design.

### Laura Kalbag — Accessibility for Everyone

Источник: [accessibilityforeveryone.site](https://accessibilityforeveryone.site/)

Что дает:

- approachable accessibility foundation;
- free-to-read resource.

Что забрать:

```text
Accessibility начинается с обычных решений: текст, контраст, разметка, клавиатура.
```

Куда в атлас: accessibility, frontend.

---

## 10. Data visualization и dashboards

### Edward Tufte — The Visual Display of Quantitative Information

Источник: [edwardtufte.com](https://www.edwardtufte.com/book/the-visual-display-of-quantitative-information/)

Что дает:

- data-ink;
- graphical integrity;
- small multiples;
- precision, comparison, density.

Что забрать:

```text
График должен увеличивать понимание данных, а не площадь украшений.
```

Куда в атлас: data visualization, dashboards.

### Cole Nussbaumer Knaflic — Storytelling with Data

Источник: [storytellingwithdata.com](https://www.storytellingwithdata.com/books)

Что дает:

- выбрать график;
- убрать clutter;
- сфокусировать внимание;
- добавить annotation;
- story around insight.

Что забрать:

```text
Данные без вопроса и вывода — просто картинка.
```

Куда в атлас: dashboards, presentations, product analytics.

### Stephen Few — Information Dashboard Design

Источник: [Perceptual Edge library](https://www.perceptualedge.com/library.php)

Что дает:

- dashboard as at-a-glance monitoring/decision tool;
- perception principles for dashboards;
- common dashboard failures.

Что забрать:

```text
Dashboard должен быть сканируемым рабочим инструментом, а не витриной графиков.
```

Куда в атлас: dashboards, admin, analytics.

---

## 11. Motion и интерактивная графика

### Val Head — Designing Interface Animation

Источник: [Rosenfeld Media](https://rosenfeldmedia.com/books/designing-interface-animation/)

Что дает:

- animation as communication;
- UI motion patterns;
- when animation helps and when it distracts.

Что забрать:

```text
Motion должен объяснять изменение, а не просто двигаться.
```

Куда в атлас: motion, behavior.

### Rachel Nabors — Animation at Work

Источник: [A Book Apart archive/listing](https://abookapart.com/products/animation-at-work.html)

Что дает:

- animation in product and brand;
- motion as part of UX and storytelling.

Что забрать:

```text
Анимация должна иметь роль в продукте: orientation, feedback, progress, delight.
```

Куда в атлас: motion, brand, visual assets.

---

## 12. Сильные сайты и документация вместо книг

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

### NN/g

Источник: [nngroup.com](https://www.nngroup.com/)

Что брать:

- research-backed usability;
- heuristics;
- patterns;
- AI UX;
- visual design;
- forms;
- navigation.

### Apple Human Interface Guidelines

Источник: [Apple HIG](https://developer.apple.com/design/human-interface-guidelines)

Что брать:

- platform-native behavior;
- accessibility;
- motion;
- color;
- typography;
- search;
- brand on iOS.

### Material Design

Источник: [Material 3](https://m3.material.io/)

Что брать:

- roles;
- components;
- motion;
- responsive;
- expressive research.

### GOV.UK Design System

Источник: [GOV.UK Design System](https://design-system.service.gov.uk/)

Что брать:

- forms;
- errors;
- plain language;
- service clarity;
- accessibility.

### IBM Carbon / Shopify Polaris / Atlassian

Источники: [Carbon](https://carbondesignsystem.com/), [Polaris](https://polaris.shopify.com/), [Atlassian](https://atlassian.design/)

Что брать:

- enterprise/admin patterns;
- empty states;
- tables;
- AI labeling;
- pictograms;
- design-system discipline.

---

## 13. Что читать первым

### Если нужно быстро прокачать вкус и UI

1. Refactoring UI.
2. Thinking with Type.
3. Universal Principles of Design.
4. NN/g visual hierarchy / gestalt / forms.
5. Apple HIG + Material roles.

### Если нужно понять UX как систему

1. The Design of Everyday Things.
2. Don't Make Me Think.
3. The Elements of User Experience.
4. About Face.
5. Information Architecture.

### Если нужно строить продукт, а не макеты

1. Just Enough Research.
2. Lean UX.
3. Sprint.
4. This Is Service Design Doing.
5. Design Systems.

### Если нужно делать сложные интерфейсы

1. Designing Interfaces.
2. Information Architecture.
3. Inclusive Components.
4. Information Dashboard Design.
5. GOV.UK + Carbon + Polaris.

---

## 14. Главный вывод

Книги нужны не для авторитета, а чтобы отделить фундамент от моды.

Мода говорит:

```text
сделай 3D / glass / cards / как Linear / как Apple
```

Фундамент спрашивает:

```text
что человек делает?
что он должен понять?
что может пойти не так?
как он восстановится?
как это масштабируется?
как это проверить?
```

Хорошая база растет из второго.

---

Документ: http://docs.gitaspen.ru/development/process/writing-a-library-document

# Как писать документ библиотеки

Требования к документам этой библиотеки. Общие правила языка и тона — в
[правилах письма](./writing-rules.md); здесь — что именно должно быть в документе, чтобы им можно
было пользоваться.

## Критерий готовности

Документ готов, когда по нему можно выполнить задачу **с нуля**, не обращаясь к другим источникам и
не додумывая шаги. Проверка простая: дайте документ человеку или языковой модели, которые темы не
знают, и посмотрите, дойдут ли они до результата. Если на каком-то шаге нужно догадаться — это
дефект документа, а не читателя.

Типичное, что теряется и ломает выполнение:

- в какой файл класть показанную конфигурацию;
- в каком порядке выполнять шаги, если один зависит от результата другого;
- что должно быть верно **до** начала;
- как понять, что шаг удался, а не «вроде прошло».

## Обязательные части

**Задача и исходное состояние.** Что получится в конце и от какого состояния система стартует.

**Предусловия с проверками.** Не «нужен домен», а команда, показывающая, что домен указывает
именно сюда, и ожидаемый результат.

**Шаги по порядку, каждый с проверкой.** У шага — команда и однозначный ожидаемый результат.
Правило для читателя: результат другой — переходить к разбору отказов, а не к следующему шагу.
Если порядок шагов принципиален, это указывается явно вместе с причиной.

**Связь с соседними этапами.** Документ описывает звено цепочки, а не изолированный приём. В начале
— короткая таблица «откуда пришли → этот документ → куда ведёт», где названы:

- что предыдущий этап обязан обеспечить (иначе этот не сработает);
- что этот этап оставляет следующему.

Эти документы выросли из практики разворачивания систем, где этапы связаны. Пропущенный стык — это
не неполнота текста, а место, где на практике останавливается работа: например, настроенный TLS
бесполезен, если приложение слушает все интерфейсы и его дёргают напрямую, минуя шлюз.

**Разбор типичных отказов.** Таблица «признак → причина → что делать». Признак формулируется так,
как читатель его видит: сообщение об ошибке, код ответа, поведение.

**Откат и снятие.** Как вернуть систему в исходное состояние и что при этом не удаляется само.

## Что обязательно для какого жанра

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

| Жанр | Примеры | Обязательно | Неприменимо |
|---|---|---|---|
| **Порядок действий** — читатель выполняет и получает работающий результат | всё в `operations/`, `process/testing.md` | все шесть частей | — |
| **Спецификация** — задаёт раскладку и инварианты, по которым пишут код | `architecture/BM*.md`, `REUSE.md` | задача, связь с соседними этапами, проверки соответствия (как убедиться, что код правилу отвечает), таблица отказов | предусловия, откат |
| **Свод правил** — по чему сверяются при работе | `PRINCIPLES.md`, `writing-rules.md`, `design/*` | задача, связь с соседними этапами | предусловия, шаги, откат |

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

Отдельно: **карта источников** (например, читательская карта книг) документом библиотеки не
считается — по ней нельзя выполнить задачу, не обращаясь наружу. Такие файлы держатся в базе как
справка и помечаются в первой строке, чтобы читатель не ждал от них исполнимости.

## Обезличивание

В библиотеке нет наших доменов, адресов, почт, имён проектов и ключей. Примеры — на `example.com` и
`admin@example.com`. Перед публикацией запускается [проверка](../../tools/check-leaks.sh); её
непустой вывод означает, что документ публиковать нельзя.

## Одна тема — один документ

Тема закрывается целиком в одном файле. Если по теме есть несколько источников, они сводятся, а не
складываются рядом: расхождения разбираются, устаревшее убирается, остаётся один порядок действий с
объяснением, когда применим каждый вариант.

---

Документ: http://docs.gitaspen.ru/development/process/writing-rules

# Правила письма: документация, статьи, тексты

Перечитывать **перед** написанием любого текста для чтения людьми: документация, README, спека,
описание формата/продукта, статья, пост, коммит-описание уровня документа. Цель — текст уровня
технической документации и научной статьи, а не маркетинга.

Свод основан на установленных гайдах (см. «Источники»): Google developer documentation style
guide, Microsoft Writing Style Guide, Wikipedia Manual of Style (Words to watch), нормы научного
письма (объективность и хеджирование claims).

---

## 1. Принципы

1. **Точность и проверяемость.** Каждое утверждение — факт, который можно проверить. Нет
   данных — нет утверждения. Спекуляций и обещаний будущего избегать.
2. **Объективность (нейтральность).** Описывать, что объект *есть* и *как работает*. Оценку
   («лучше», «удобнее», «мощнее») выводит читатель из фактов, а не из прилагательных автора.
3. **Ясность.** Короткие предложения, простые слова, термин определён при первом употреблении.
   Пишут для беглого просмотра, потом для чтения.
4. **Краткость.** Без лишних слов и квалификаторов. Одна мысль — одно предложение.
5. **Аудитория.** Сначала задача читателя, потом перечисление возможностей. Уровень знаний
   читателя — явный.
6. **Единообразие.** Один термин на одно понятие во всём тексте. Не синонимизировать термины.
7. **Структура.** Заголовки по смыслу, списки для перечислений, нумерация для
   последовательностей, таблицы для сопоставления параметров.

---

## 2. Чего избегать (с примерами)

### 2.1. Хвастовство и превосходство (puffery / peacock)
Субъективная похвала без проверяемого содержания.

Слова-маркеры: *лучший, единственный, революционный, передовой, мощный, непревзойдённый,
уникальный, не имеющий аналогов, фантастический, гениальный, world-class, cutting-edge*.

- Плохо: «формат даёт свойства, которых нет ни у Word, ни у PDF».
- Плохо: «PDF фиксирует, но не редактируется; Word редактирует, но не фиксирует — мы делаем
  и то, и другое».
- Хорошо: привести факты в нейтральной форме (что формат делает) и, при необходимости,
  таблицу свойств без оценочных выводов. Вывод о преимуществе читатель сделает сам.

### 2.2. Принижение альтернатив
Сравнение через недостатки чужого продукта — это позиционирование, не документация.

- Плохо: «без главного недостатка PDF», «в отличие от громоздкого Word».
- Хорошо: «PDF фиксирует раскладку и не предполагает редактирования исходного содержимого»
  (факт об альтернативе, без оценки) — и отдельно факты о своём формате. Сравнение допустимо
  **только** как нейтральная таблица параметров с проверяемыми значениями.

### 2.3. Расплывчатые отсылки (weasel words)
Видимость авторитета без источника.

Маркеры: *многие считают, как известно, эксперты говорят, исследования показывают, принято
считать, общеизвестно*.

- Плохо: «многие переходят на открытые форматы».
- Хорошо: убрать, либо дать конкретный источник/число.

### 2.4. Редакторские вставки
Навязывают читателю, что считать важным.

Маркеры: *очевидно, конечно, разумеется, ясно, интересно, к сожалению, на самом деле, просто,
стоит отметить, важно понимать*.

- Плохо: «очевидно, это удобнее».
- Хорошо: убрать слово; факт говорит сам.

### 2.5. Преувеличение и категоричность (нет хеджирования)
Сильное утверждение требует сильного доказательства. Где знание неполное — смягчать.

- Сильные глаголы (доказывает, гарантирует, всегда, никогда) — только при доказательстве.
- Где результат вероятностный/эвристический — «как правило», «в типичном случае», «может»,
  с указанием условий и ограничений.
- Плохо: «импорт PDF восстанавливает структуру документа».
- Хорошо: «импорт PDF восстанавливает структуру эвристически; точность зависит от исходного
  файла (для сканов — режим “страница как изображение”)».

### 2.6. Непроверяемые числа и обещания
- Плохо: «весит в разы меньше».
- Хорошо: «для типового договора (≈3 страницы, один шрифт) — ≈35–65 КБ» с пометкой, что это
  оценка, и от чего зависит. Незавершённое помечать как план, а не как факт.

---

## 3. Как писать (DOs)

- Описывать **факты и механику**: что делает, как устроено, при каких условиях, с какими
  ограничениями.
- Утверждение → по возможности рядом основание (число, ссылка, пример).
- Термины и аббревиатуры раскрывать при первом употреблении.
- Активный залог, настоящее время, прямое обращение в инструкциях («нажмите», а не «должно быть
  нажато»).
- Честно отделять **готово** от **в работе/план**. Незавершённое — отдельным разделом «Состояние».
- Ограничения и компромиссы называть прямо — это повышает доверие, а не снижает его.
- Нейтральная лексика передачи речи: *сказал, описал, согласно* (не *признал, разоблачил,
  заявил*).

---

## 4. Чеклист перед публикацией

- [ ] Нет слов из §2.1–2.4 (хвастовство, принижение, weasel, редакторские вставки).
- [ ] Каждое сравнение — проверяемый факт, а не оценка; принижения альтернатив нет.
- [ ] Сильные утверждения подкреплены; неполное знание — смягчено и с условиями.
- [ ] Числа/обещания проверяемы или помечены как оценка/план.
- [ ] Термины определены; один термин на понятие.
- [ ] Готовое отделено от планируемого.
- [ ] Текст читается как описание, а не как реклама.

---

## Источники
- Google developer documentation style guide — developers.google.com/style (highlights, word list).
- Microsoft Writing Style Guide — learn.microsoft.com/style-guide (clarity, brevity, voice).
- Wikipedia Manual of Style / Words to watch — puffery, weasel words, editorializing, contentious
  labels, expressions of doubt.
- Нормы научного письма: объективный тон и хеджирование claims (избегать overstatement;
  «may/suggests/appears» вместо категоричного, с указанием ограничений).

---

Документ: http://docs.gitaspen.ru/development/process/rules

# Работа в кодовой базе

Как вносится отдельное изменение в код: с чем сверяются до, как правят, что проверяют перед сдачей
и что делают при расхождении с архитектурой. Правила одинаковы для человека и для ИИ-помощника: у
кода два равных пользователя, оба действуют одной логикой и видят одни и те же документы
([PRINCIPLES](../architecture/PRINCIPLES.md)).

Как код раскладывается по слоям — в архитектурных документах. Здесь то, что происходит вокруг
каждой правки независимо от слоя.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| код разложен по слоям (BMAP / BMFP / BMBP / BMGP) | как вносится и проверяется отдельное изменение | [прогон тестов](./testing.md), [фиксация в истории](./git-and-repositories.md) |

Что предыдущий этап обязан обеспечить: границы слоёв заданы и известны. Пока неизвестно, где
проходит граница, выражение «правка на своём месте» не имеет содержания.

Что этот документ оставляет следующему: изменение, ограниченное одной задачей и одним слоем. Такое
изменение можно просмотреть, проверить и откатить целиком; смешанное не просматривается и не
откатывается по частям.

---

## С чем сверяются перед изменением

Архитектура — источник истины: она задаёт, где изменение окажется и какие импорты ему доступны.
Сверка — это не перечитывание документа целиком, а обращение к разделу, который ограничивает
текущую правку.

| Что меняется | Что перечитывается | Что оттуда нужно |
|---|---|---|
| любой код | [PRINCIPLES](../architecture/PRINCIPLES.md) | контракт на границе, отсутствие знания о конкретном хозяине |
| место пакета в репозитории, связь фронта и бэка | [BMAP](../architecture/BMAP.md) | корни репозитория, форма конверта |
| фронт | [BMFP](../architecture/BMFP.md) | слои, инварианты, именование, порядок добавления фичи |
| бэк | [BMBP](../architecture/BMBP.md) | слои, инварианты, DI, идемпотентность, транзакции |
| шлюз | [BMGP](../architecture/BMGP.md) | маршруты, upstream, что живёт на шлюзе, а что в сервисе |
| код, у которого появился второй потребитель | [REUSE](../architecture/REUSE.md) | когда выделять единицу и как её подключать |

**Проверка перед началом:** назвать слой, в котором окажется правка, и правило этого слоя, которое
её ограничивает. Слой не называется — место для изменения ещё не выбрано; выбор места идёт до
написания кода, а не после.

---

## Правка вносится точечно

Меняется то, что относится к задаче, и ничего вокруг.

**Файл не переписывается целиком ради одного изменения.** Файл, выданный заново, даёт диф, в
котором изменённым выглядит всё: просмотр перед вливанием превращается в повторное чтение файла, а
перенос строк и переформатирование прячут содержательную правку. Точечное изменение видно за один
экран.

**Одно изменение — один коммит.** Переименование, переформатирование и смена стиля идут отдельными
коммитами, не вперемешку с правкой поведения — иначе их нельзя ни просмотреть, ни откатить по
отдельности ([git и репозитории](./git-and-repositories.md)). Гранулярность — это и есть механизм
отката: обратным коммитом снимается ровно то, что лежит в отдельном коммите.

**Результат — рабочий код.** Псевдокод, фрагмент «дальше по аналогии» и пример вместо реализации
оставляют работу незаконченной. Незаконченное называется прямо, а не маскируется заглушкой.

**Задел «на будущее» не пишется.** Абстракция под воображаемого второго потребителя — тот же
костыль, только наоборот: единица выделяется, когда переросла место, а не заранее
([REUSE](../architecture/REUSE.md)).

---

## Расхождение с архитектурой не выполняется молча

Запрос, противоречащий архитектуре, не выполняется буквально и не отклоняется без объяснения.
Порядок: назвать расхождение, назвать его следствие, предложить решение в рамках канона, выполнить
согласованный вариант.

Расхождение формулируется как проблема и следствие, а не как предпочтение: «бизнес-логика попадёт в
клиент — при смене транспорта её придётся переносить», а не «так не принято».

Признаки, при которых сверка обязательна до написания кода:

- нарушается граница слоя: `api` идёт в хранилище, `boundary` зовёт клиент напрямую;
- в одном месте смешаны ответственности — транспорт и правило предметной области;
- значение зашивается в код вместо настройки: адрес, лимит, ключ, имя конкретного потребителя;
- бизнес-правило переезжает в API или в UI;
- в переиспользуемой единице появляется ветвление по имени её потребителя.

Порядок разрешения противоречий: архитектура → установившаяся практика → простота → форма кода →
форма запроса. Формулировка запроса уступает архитектуре, но расхождение проговаривается, а не
обходится молча: невысказанное возражение выглядит как согласие.

---

## Код читается как соседний

Новый код не выделяется в файле. Ориентир — не общий вкус, а то, что уже лежит рядом.

**Имена — по правилам своей архитектуры.** Суффиксы ролей и алиасы слоёв на фронте (BMFP, раздел
«Именование»), имена классов слоёв и фабрик DI на бэке (BMBP, раздел «Именование»). Одно понятие —
одно имя во всём коде.

**Комментариев столько же, сколько вокруг.** Комментарий объясняет «почему», а не пересказывает
«что»: пересказ устаревает при первой правке и начинает врать. Оформление — по стилю кода
соответствующей стороны: [фронт](./frontend-code-style.md), [бэк](./backend-code-style.md).

**Структура файла и форма импорта — как у соседей.** Абсолютные импорты через алиасы слоёв, тот же
порядок объявлений, то же разбиение на файлы.

**Готовое вперёд самопала.** Зрелая библиотека и решение из соседнего модуля идут раньше
собственной реализации. Новая зависимость вводится, только когда задача не решается тем, что в
проекте уже есть.

**Простое вперёд краткого.** Меньше вложенности, меньше ветвлений, меньше неявных побочных
эффектов; композиция вместо наследования; явная структура вместо магии. Плотная запись, которую
приходится расшифровывать, — не простота.

---

## Границы слоёв не нарушаются ради краткости

Вызов «через слой» короче на несколько строк и стоит потери границы: слой перестаёт быть
заменяемым, а тест на него перестаёт что-либо доказывать.

| Сокращение | Что ломается |
|---|---|
| `api` обращается к репозиторию мимо `core` | правило предметной области оказывается в транспорте и не проверяется без него |
| `boundary` зовёт клиент или хранилище напрямую | UI начинает знать форму ответа сервера; смена транспорта задевает экраны |
| в `infrastructure` появляется бизнес-правило | правило дублируется при втором адаптере, и тесты слоя логики его не видят |
| `shared` импортирует вышележащий слой | появляется цикл, и `shared` больше нельзя переиспользовать отдельно |

Пометка «временно» здесь не работает: у временного решения нет ни срока, ни владельца, и оно
остаётся в коде. Если правило действительно требует нарушения границы, граница проведена не там —
это разбирается отдельно и меняет архитектурный документ, а не обходится в одном файле.

---

## Что делать при неопределённости

Неопределённость — не повод остановиться и не повод угадать.

1. Выполняется часть, которая от неопределённости не зависит. Как правило, это большая часть
   работы, и она не переделывается при любом ответе.
2. Вопрос задаётся как выбор из названных вариантов с последствиями каждого, а не как «что делать?».
   Вопросы собираются в один список, а не выдаются по одному.
3. Допущение, принятое без подтверждения, записывается рядом с изменением — в описании коммита или
   в запросе на просмотр. Непроговорённое допущение проверяющий не отличит от согласованного
   решения.

Спрашивают, когда без ответа затрагивается видимое снаружи: контракт, форма ответа, схема данных,
права, поведение при отказе. Внутреннее устройство — выбор реализации, разбиение на файлы, имена
внутри слоя — решается на месте по соседнему коду.

---

## Очевидная неэффективность

Преждевременная оптимизация не выполняется: код пишется простым, узкие места ищутся измерением. Но
известная заранее неэффективность не вносится:

- бэк — блокирующий вызов в асинхронном коде, запрос в цикле вместо одного запроса, выборка всей
  таблицы ради одной строки;
- фронт — перерисовка экрана из-за одного значения, состояние и эффекты там, где достаточно
  вычисляемого значения.

---

## Проверка перед сдачей

Прогоняется:

```bash
<команда прогона тестов>     # ожидается: все тесты прошли, код возврата 0
echo $?                      # ожидается: 0
git diff --stat              # ожидается: в списке только файлы, относящиеся к задаче
```

Что покрывать и какими тестами — [тестирование](./testing.md). Исправление ошибки начинается с
теста, который падает до правки и проходит после.

Инварианты слоёв проверяются поиском по импортам, а не чтением:

```bash
grep -rn "@infrastructure/clients" <каталог boundary>          # ожидается: пусто
grep -rn "<импорт транспортного фреймворка>" <каталог core>    # ожидается: пусто
```

Глазами смотрят то, чего тесты не покажут:

- **диф целиком** — не осталось ли отладочного вывода, закомментированного кода, случайного
  переформатирования и правок в файлах, к задаче не относящихся;
- **значения окружения** — адресов и ключей в коде нет, они приходят из настроек
  ([git и репозитории](./git-and-repositories.md));
- **фронт** — экран в состояниях загрузки, ошибки и пустых данных, на узком экране тоже
  ([правила дизайна](../design/DESIGN_RULES.md), [адаптивность](../design/RESPONSIVE.md));
- **бэк** — ответ ручки целиком: конверт, код ошибки, запись в журнале при отказе.

Изменения в документации проекта проверяются по [правилам письма](./writing-rules.md).

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| диф красный целиком, содержательная правка в нём не видна | файл выдан заново вместо изменения | вернуть исходный файл и внести только относящееся к задаче; переформатирование — отдельным коммитом |
| просмотр изменения занимает столько же, сколько написать заново | в одном коммите смешаны правка поведения и переименования | разнести по коммитам: одно изменение — один коммит |
| «временное» нарушение слоя осталось в коде | у временного решения не было ни срока, ни владельца | вернуть правило в его слой; сокращение пути не окупается |
| правка сделана, но поведение изменилось не там, где ожидали | изменение внесено не в тот слой | перенести туда, где живёт правило: проверяемое только через интерфейс лежит не на своём слое |
| ошибка вернулась после исправления | изменение проверено просмотром, а не прогоном | прогнать набор; сначала воспроизводящий тест, потом правка |
| код работает, но выглядит в файле чужим | имена и стиль взяты не из соседнего кода | привести к правилам именования своей архитектуры |
| запрос выполнен буквально, граница слоя нарушена | расхождение не было проговорено | вернуть по канону и назвать расхождение; молчаливое согласие дороже спора |
| ссылка на архитектурный документ никуда не ведёт | имя взято из чужого свода | сверить с составом раздела `architecture/`: PRINCIPLES, BMAP, BMFP, BMBP, BMGP, REUSE |

---

Документ: http://docs.gitaspen.ru/development/process/git-and-repositories

# Работа с git и репозиториями

Повседневные правила: что попадает в историю, как выглядит коммит, как ведутся ветки и версии, что
делать при ошибке. Границы репозиториев и выделение переиспользуемых единиц — в
[REUSE](../architecture/REUSE.md); здесь то, что происходит внутри репозитория каждый день.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| решено, где границы репозиториев | что коммитим, как ведём ветки и версии | сборка релиза из зафиксированного состояния |

Что этот документ оставляет следующему: помеченное версией состояние, из которого воспроизводимо
собирается релиз. Сборка из репозитория с незакоммиченными правками невоспроизводима — по артефакту
нельзя понять, что в нём.

---

## Что попадает в историю

| Попадает | Не попадает |
|---|---|
| исходный код | секреты, пароли, ключи, токены |
| конфигурация без секретов | файлы окружения с реальными значениями |
| миграции схемы | результаты сборки, каталоги зависимостей |
| документация проекта | временные и системные файлы редакторов |
| примеры файлов окружения без значений | большие бинарные артефакты (для них — отдельное хранилище) |

Файл `.gitignore` заводится **первым**, до первого коммита: файл, попавший в историю, оттуда не
исчезает при последующем добавлении в игнор — он остаётся во всех прошлых состояниях.

Рядом с файлом окружения держат его пример с теми же ключами и пустыми значениями: по нему видно,
что нужно задать при развёртывании.

---

## Коммит

Коммит — законченная единица: он делает одно изменение целиком и оставляет проект в рабочем
состоянии. Коммит, который «доломает следующий», ломает и возможность откатиться на него.

Сообщение отвечает на вопрос «что меняется и зачем», а не пересказывает диф:

```
fix(gateway): не терять заголовок авторизации при перенаправлении

Перенаправление собирало новый запрос без заголовков, и клиент получал 401
после первого же редиректа.
```

- первая строка — суть, в настоящем времени, без точки в конце;
- тело — причина и следствие, если они не очевидны;
- одно изменение — один коммит: смешанные в одном коммите правка ошибки и переименование файлов
  невозможно ни просмотреть, ни откатить по отдельности.

Служебные пометки об инструментах и соавторстве в сообщение не добавляются, если это не оговорено:
история должна отражать содержание изменения.

---

## Ветки

Основная ветка всегда в рабочем состоянии: из неё в любой момент собирается релиз.

Работа ведётся в отдельной ветке и вливается, когда закончена и проверена. Долгоживущие ветки
избегают: чем дольше ветка живёт, тем дороже слияние, и тем позже обнаруживается конфликт замысла,
а не только текста.

Ветка называется по задаче: `feat/<что>`, `fix/<что>`, `chore/<что>`.

Постоянные ветки под конкретного потребителя общего кода не создаются — это форк, который
расходится с источником. Различия потребителей выражаются настройкой, а не веткой.

---

## Версии и метки

Готовое к поставке состояние помечается меткой. Метка неизменяема: она указывает на конкретное
состояние, и переставлять её на другое нельзя — иначе «та же версия» будет означать разный код.

Нумерация по смыслу изменения:

| Часть | Меняется, когда |
|---|---|
| старшая | контракт сломан, потребителю нужно менять код |
| средняя | добавлена возможность, старое продолжает работать |
| младшая | исправление без изменения поведения |

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

---

## Просмотр изменений

Изменение просматривается до вливания. Что проверяется: соответствие архитектурным границам,
отсутствие секретов, наличие проверок, понятность имён.

Просмотр — про содержание, а не про вкус. Замечание формулируется как проблема и её следствие
(«здесь бизнес-логика попала в клиент — при смене транспорта её придётся переносить»), а не как
предпочтение.

---

## Ошибки и как из них выходить

**Секрет попал в репозиторий.** Удаление файла следующим коммитом **не помогает**: значение
остаётся в истории и в клонах. Порядок действий: отозвать и заменить значение (оно
скомпрометировано), затем при необходимости переписать историю и предупредить всех, у кого есть
клоны. Отзыв обязателен, чистка истории — вторична.

**Ошибочный коммит уже в общей ветке.** Исправляется обратным коммитом, а не переписыванием общей
истории: перезапись ломает работу всем, у кого есть клоны.

**Изменения потерялись.** Локально почти ничего не пропадает бесследно: `git reflog` показывает
состояния, на которых был репозиторий, включая те, что уже не видны из веток.

**Тяжёлый файл попал в историю.** Репозиторий не уменьшится сам: объект остаётся во всех прошлых
состояниях. Крупные файлы кладут в отдельное хранилище с самого начала.

---

## Обязательное перед выкладыванием

- в истории нет секретов — проверяется поиском по репозиторию **и по истории**, а не по текущим
  файлам;
- в репозитории лежит пример файла окружения, а не сам файл;
- рабочее дерево чисто, состояние помечено;
- описание проекта отвечает, что это, как запустить и как проверить, что запустилось.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| сборка релиза отказывается стартовать | незакоммиченные правки | зафиксировать или убрать; релиз собирается из чистого состояния |
| файл в `.gitignore`, но продолжает отслеживаться | попал в историю раньше игнора | убрать из индекса; при секрете — отозвать значение |
| репозиторий разросся до гигабайтов | бинарные артефакты в истории | вынести в отдельное хранилище; историю чистить отдельной операцией |
| слияние ветки превращается в отдельную работу | ветка жила слишком долго | вливать чаще, дробить задачу |
| «та же версия» ведёт себя по-разному | метку переставили | метки неизменяемы, выпустить новую |
| откат на прошлый коммит ломает сборку | коммиты не самодостаточны | один коммит — одно законченное изменение |

---

Документ: http://docs.gitaspen.ru/development/process/backend-code-style

# Стиль кода бэкенда

Как выглядит код внутри слоя: имена, типизация, исключения, асинхронность, записи в журнал,
комментарии, размер единиц. Раскладка по слоям, иерархия маршрутов, конверт ответа, три модели
данных, идемпотентность, транзакции и таймауты — в [BMBP](../architecture/BMBP.md). Формат записи
журнала, уровни и срок хранения — в [наблюдении за системой](../operations/observability.md).
Хранение самих секретов — в [работе с секретами](../operations/secrets.md). Здесь только то, что
решается при написании модуля.

Примеры — на Python: язык, на котором описанная раскладка обкатана (BMBP, раздел о стеке
реализации). Правила, кроме синтаксических частностей, от языка не зависят.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| код разложен по слоям, фабрики зависимостей заведены (BMBP); контракт ответа задан | как пишется отдельный модуль внутри слоя | [тестирование](./testing.md): зависимости подменяются без правки кода, отказы проверяются по типу |

Что предыдущий этап обязан обеспечить: каталог по слоям и явную сборку зависимостей (`get_*` в
`api`, `build_*` ниже). Без них запреты по слоям нечего проверять, а подмена зависимости в тесте
требует подмены модулей.

Что этот документ оставляет следующему: модули, в которые зависимость приходит параметром, а отказ
выражен типом исключения. Тест такого модуля не поднимает базу и не читает окружение.

---

## Именование

| Что | Правило | Пример |
|---|---|---|
| Модуль | роль в слое | `service`, `repo`, `router`, `schemas`, `dependencies`, `models`, `client`, `mapper`, `config` |
| Функция-действие | глагол предметной области | `place_order`, `verify_code`, `revoke_session` |
| Функция-предикат | `is_*` / `has_*`, возвращает `bool` | `is_blocked`, `has_active_session` |
| Конструктор значения | существительное или `new_*` | `new_id`, `now_iso` |
| Приватный помощник | ведущее подчёркивание | `_connect`, `_parse_payload` |
| Метод хранилища | фиксированный словарь глаголов | `get_by_id`, `list_orders`, `save_order`, `update_order`, `delete_order` |

Словарь методов хранилища закреплён: `get_*` — чтение одной записи по ключу, `list_*` — выборка,
`save_*` — создание либо обновление, `delete_*` — удаление. Одинаковые операции во всех
репозиториях называются одинаково, поэтому незнакомый репозиторий читается без изучения.

**Имя не повторяет контекст.** В классе `UserRepo` достаточно `get_by_id`: `get_user_by_id_from_db`
повторяет то, что уже сказано классом и слоем.

**Одно понятие — одно слово во всём сервисе.** Если в модели хранения `account`, то в DTO, схеме и
маршруте тоже `account`. Синонимы вперемешку («account», «profile», «user» об одном и том же)
заставляют держать в голове таблицу соответствий.

**Модуль не называется `utils`, `helpers`, `common`.** Такое имя ничего не запрещает положить
внутрь, и модуль растёт без предела. Общий фундамент слоя оформляется каталогом со служебным
префиксом (`_shared`), но каждый файл в нём называется своей ролью.

---

## Типизация и схемы

**Аннотации обязательны** на параметрах и возвращаемых значениях, включая `-> None`. Слой должен
проверяться статически: без аннотаций нарушение границы обнаруживается только при выполнении.

**Необязательное — `X | None`,** одна форма записи на весь сервис. Две записи одного и того же в
соседних модулях создают впечатление, что различие есть.

**Домен — типизированная структура, а не словарь.** Словарь не даёт ни проверки опечатки в имени
поля, ни списка полей при чтении. Неизменяемое значение объявляется `frozen=True`, часто
создаваемое — со `slots=True`.

**Перечисление — строковое** (`StrEnum`): значение совпадает со строкой контракта, сравнение со
строкой работает, в журнал попадает читаемое имя, а не число.

**Разрешённые переходы описаны в самом типе.** Метод перехода проверяет, допустим ли следующий
статус из текущего, и отказывает, если нет. Проверка, размазанная по вызывающему коду,
расходится: один сценарий её делает, второй забывает.

```python
def transition(self, next_status: OrderStatus) -> None:
    if next_status not in ALLOWED[self.status]:
        raise ValueError(f"invalid transition: {self.status} -> {next_status}")
    self.status = next_status
```

**Зависимость объявляется протоколом в `core`, реализация лежит в `infrastructure`.** `core`
типизируется структурным интерфейсом хранилища; реализаций может быть две — на базе и в памяти.
Тест подставляет вторую как обычный аргумент, без подмены модулей на уровне импортов.

**Ограничения значений — в схеме, не в обработчике.** Длина, диапазон, обязательность и формат
объявляются полем схемы (`min_length`, `max_length`, `ge`, `le`). Такая проверка попадает в
документацию API и даёт единообразный отказ; проверка `if len(name) > 80` внутри обработчика не
делает ни того, ни другого.

**Вход и выход настроены по-разному:**

| | Входящая схема | Исходящая схема |
|---|---|---|
| лишние поля | запрещены | игнорируются |
| приведение типов | выключено | не применяется |
| зачем так | опечатка в имени поля и строка вместо числа должны быть отказом, а не тихо принятым значением | модель хранения может иметь поля, которых нет в ответе |

**Список в ответе — с фабрикой по умолчанию** (`default_factory=list`): пустой список вместо `null`
избавляет потребителя от лишнего ветвления. Изменяемое значение по умолчанию задаётся только
фабрикой — литерал `[]` или `{}` в сигнатуре разделяется всеми вызовами.

---

## Настройки

Настройки читаются один раз, в одном модуле, и передаются вниз параметром. Модуль в `core` не
обращается к переменным окружения: иначе его нельзя выполнить в тесте, не подменив окружение
процесса.

У секрета нет значения по умолчанию в коде. Отсутствие переменной должно останавливать старт —
подставленное умолчание работает и в рабочей среде, где отсутствие настройки уже никто не заметит.
Адреса соседних систем тоже приходят из настроек: адрес, вписанный в код, переезжает вместе с ним
во все среды.

---

## Исключения

**Ожидаемый отказ — доменное исключение из `core`,** унаследованное от подходящего встроенного
типа:

```python
class NotFoundError(LookupError): ...
class ConflictError(ValueError): ...
class AuthenticationError(PermissionError): ...
```

Наследование от встроенного типа позволяет вызывающему поймать группу
(`except (ValueError, LookupError)`), не перечисляя все доменные классы и не обновляя перечисление
при появлении нового.

**Доменное исключение не знает кода ответа.** Соответствие «исключение → код» задаётся в `api`
одним переводчиком на сервис; собирать ответ об ошибке в каждом обработчике не нужно (форма ответа
— BMBP, «Фабрика эндпойнтов»).

**Отсутствие записи при чтении — не исключение.** Хранилище возвращает `X | None`. Исключение
поднимает тот слой, для которого отсутствие записи нарушает сценарий: одному вызову «нет записи»
означает отказ, другому — что нужно создать новую.

**Перехват — узким типом.** `except Exception` уместен в двух местах: в общем обработчике
приложения и на границе фоновой задачи. Оба логируют со стеком и не продолжают работу с неполным
результатом.

**Причина сохраняется:** `raise Translated(...) from error`. Без `from` исходный стек теряется, и в
журнале остаётся только внешняя ошибка без места, где всё началось.

**Отказ соседа различается по виду.** «Сосед ответил отказом» и «соседа нет» — разные ситуации:
первая означает отказ клиенту, вторая — временную недоступность и другой код наружу. Клиент
внешней системы возвращает два разных исключения, а не одно общее.

Запрещено:

- `except …: pass` и молчаливый `return None` вместо отказа;
- перехват с записью в журнал и продолжением по тому же пути, как будто отказа не было;
- исключение как штатный поток управления;
- транспортное исключение из `infrastructure` (BMBP, инвариант 5).

---

## Асинхронность

**`async def` — только там, где есть `await`.** Асинхронная функция без ожидания навязывает
вызывающему асинхронный вызов и ничего не даёт взамен.

**Блокирующий вызов выносится в поток:** `await asyncio.to_thread(...)`. Блокирующая операция в
цикле событий останавливает обработку всех запросов процесса, а не только собственного.

**У сетевого клиента задан таймаут, и клиент закрывается:**

```python
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
    response = await client.get(url, headers=headers)
```

Клиент, созданный на каждый запрос и не закрытый, удерживает соединения до исчерпания лимита
дескрипторов. Ожидание чужого ответа ограничивается и там, где таймаут клиенту не передашь:
`asyncio.wait_for(..., timeout=…)`.

**Ресурс освобождается контекстным менеджером,** а не парой «открыл — закрыл» в разных ветках: при
исключении вторая половина пары не выполняется.

**Остановка фоновой работы — в три шага:** сигнал завершения, ожидание с таймаутом, принудительное
завершение. Без третьего шага остановка сервиса зависает на одной задаче.

**Общий изменяемый ресурс под конкурентной записью — под блокировкой.** Асинхронность не отменяет
гонок: переключение происходит на каждом `await`.

**Работа при импорте не выполняется.** На верхнем уровне модуля остаются объявления и дешёвые
константы. Подключение к базе, чтение файлов и сетевые вызовы делаются в функции: иначе импорт
модуля — в том числе при сборе тестов и генерации документации — требует полного рабочего
окружения.

---

## Записи в журнал

Формат записи, уровни и срок хранения — в наблюдении за системой. Со стороны кода:

- **настройка журнала одна на процесс** и делается в точке входа; прикладные модули только пишут;
- **логгер берётся по имени модуля** (`logging.getLogger(__name__)`) — источник записи виден без
  вписывания названия в текст сообщения;
- **аргументы передаются логгеру, а не собираются в строку заранее:**
  `logger.info("order accepted %s", order_id)`. При выключенном уровне строка не форматируется;
- **отказ пишется со стеком** (`exc_info=True`): по одному тексту сообщения место не находится;
- **`print` — не журнал:** вывод идёт мимо уровня и формата, его нельзя выключить и трудно
  собрать;
- **процесс пишет в стандартный вывод,** файлы из кода не открываются: сбор и ротацию делает среда;
- **шумные сторонние логгеры приглушаются в настройке,** одним местом, а не подавлением по вызову.

### Чего в записи нет

Пароли, токены и ключи в журнал не попадают — это общее правило (наблюдение за системой). Со
стороны кода к ним добавляется то, что в журнал попадает чаще всего по недосмотру:

| Не пишется | Что писать вместо |
|---|---|
| одноразовый код подтверждения, ссылка восстановления | идентификатор попытки и её исход |
| номер телефона, адрес почты, полное имя, адрес | идентификатор учётной записи |
| тело запроса и заголовки целиком | имена полей, размер, код ответа |
| значение ключа идемпотентности или сессии | необратимый отпечаток либо идентификатор |

Одноразовый код, попавший в журнал, перестаёт быть одноразовым: журнал доступен шире и живёт
дольше, чем канал доставки кода. Персональные данные попадают в журнал вместе с диагностикой и
уезжают туда, где хранение персональных данных не предполагалось.

Правило совпадает с тем, как секреты хранятся: в базе лежит необратимый отпечаток, сравнение идёт
по отпечатку и постоянным по времени сравнением (`hmac.compare_digest`). Значение существует
ровно в момент проверки — ни в хранилище, ни в журнале его нет.

---

## Комментарии

Комментарий объясняет причину или ограничение, а не действие. «Почему выбран такой порядок»,
«почему предел именно такой», «что произойдёт при отказе» — полезны; пересказ следующей строки
устаревает раньше неё.

- **Путь файла в первой строке модуля не пишется:** он виден в дереве и расходится с реальностью
  при первом переносе.
- **Закомментированный код удаляется** — прежние варианты хранит история репозитория.
- **Документирующая строка нужна там, где поведение не следует из имени:** инварианты, единицы
  измерения, побочные эффекты, поведение при отказе. Пересказ сигнатуры не нужен.
- **`TODO` сопровождается условием снятия.** Без условия пометка остаётся навсегда и перестаёт
  читаться.

---

## Размер

| Единица | Ориентир | Что означает превышение |
|---|---|---|
| функция | до 40 строк, один уровень абстракции | внутри спрятан отдельный шаг — он выделяется функцией |
| обработчик API | 5–20 строк | в обработчик просочилась логика |
| класс-сервис | одна доменная зона | зона делится, межзональное уходит в сценарий |
| модуль | до 300 строк | в модуле больше одной роли |

Ориентиры — признаки, а не запреты: длинное перечисление допустимых переходов или каталог кодов
ошибок остаются одним куском по смыслу.

**Повторяющаяся обвязка выносится.** Если каждый метод сервиса начинается одинаковым `try/except`
с одинаковой записью в журнал, обвязка переносится в одно место. Модуль, собранный из
повторяющихся блоков, растёт линейно от числа сценариев и правится в десятке мест сразу.

**Параметры после `*` передаются по имени:**

```python
def create_order(self, *, customer_id: str, items: list[Item], comment: str = "") -> Order: ...
```

Вызов читается без обращения к сигнатуре, а перестановка параметров в объявлении не меняет смысл
вызовов молча. Больше четырёх-пяти параметров — передаётся объект, а не список аргументов.

---

## Что не появляется в коде слоя

Инварианты слоёв заданы в BMBP; здесь — как они выглядят при поиске по коду.

| Слой | Не появляется | Как проверить |
|---|---|---|
| `api` | запросы к базе и ORM, вызовы внешних клиентов, бизнес-правила, сборка ответа об ошибке по месту | поиск импортов моделей и репозиториев, имени драйвера базы в `api/` |
| `core` | импорт транспортного фреймворка, чтение заголовков и куки, конструирование HTTP-ответов, обращение к окружению | поиск имени веб-фреймворка и `os.environ` в `core/` |
| `infrastructure` | бизнес-правила, транспортные исключения, вызовы сервисов и сценариев `core` | поиск импортов `core.services`, `core.solutions` в `infrastructure/` |
| `shared` | импорт любого другого слоя, состояние процесса, обращения к сети и базе | поиск импортов остальных слоёв в `shared/` |

Проверка выполняется поиском по импортам и запускается вместе с тестами: нарушение границы —
такой же отказ сборки, как несобравшийся тип.

---

## Чеклист перед ревью

- [ ] у всех функций аннотированы параметры и результат;
- [ ] зависимости приходят параметром, окружение читается только в модуле настроек;
- [ ] ожидаемый отказ выражен доменным исключением, код ответа назначает `api`;
- [ ] нет перехвата `Exception` вне общего обработчика и границы фоновой задачи;
- [ ] у каждого внешнего вызова есть таймаут, клиент закрывается;
- [ ] `async def` без `await` отсутствует, блокирующие вызовы вынесены в поток;
- [ ] в записях журнала нет кодов, персональных данных, тел запросов и заголовков;
- [ ] нет `print`, закомментированного кода и пути файла в первой строке;
- [ ] обработчик короткий, повторяющаяся обвязка вынесена;
- [ ] запреты по слоям выдерживают поиск по импортам.

---

Документ: http://docs.gitaspen.ru/development/process/frontend-code-style

# Стиль кода фронтенда

Как выглядит код внутри одного файла компонента: структура разметки, вложенность стилей, имена,
комментарии, приёмы взаимодействия. Раскладка по слоям, границы импортов и правило «стиль лежит
рядом с компонентом» — в [BMFP](../architecture/BMFP.md). Роли цвета, типографика, обязательные
состояния экрана — в [правилах дизайна](../design/DESIGN_RULES.md), единицы и точки перестроения —
в [адаптивности](../design/RESPONSIVE.md). Здесь только то, что решается внутри файла.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| код разложен по слоям, стиль лежит рядом с компонентом (BMFP); токены темы заданы (правила дизайна) | как пишется отдельный компонент: разметка, вложенность стилей, имена, взаимодействие | ревью и [тестирование](./testing.md): поведение проверяется через границу компонента |

Что предыдущий этап обязан обеспечить: алиасы слоёв в конфигурации сборки и типов, набор токенов
темы (цвет, отступ, радиус, типографика). Без токенов правила ниже вырождаются в спор о конкретных
значениях в каждом файле.

Что этот документ оставляет следующему: компоненты, у которых наблюдаемое поведение — клик,
наведение, фокус — задано в одном месте и проверяется без знания внутренней разметки.

---

## Разметка

**Обёртка заводится под задачу.** Элемент-контейнер оправдан, когда он задаёт раскладку,
ограничивает область или служит границей группы. Обёртка «на всякий случай» удлиняет дерево: каждый
уровень — ещё одна точка, где протекает отступ и теряется контекст наложения.

**Тег выбирается по роли.** Ссылка — `<a>`, кнопка — `<button>`: клавиатура, фокус и контекстное
меню браузера работают без дополнительного кода. Минимум по доступности — в правилах дизайна.

### Кликабельная карточка целиком

Задача: вся площадь карточки ведёт по ссылке, но в разметке остаётся одна ссылка, а не ссылка
вокруг всего содержимого.

```tsx
<div className={styles.card}>
  <a className={styles.link} href={href} aria-label={title} />
  <h3 className={styles.title}>{title}</h3>
  <p className={styles.text}>{text}</p>
</div>
```

```scss
.card {
  position: relative;

  &:hover .title { color: var(--color-text-strong); }
  &:focus-within { outline: 2px solid var(--color-focus); }

  .link {
    position: absolute;
    inset: 0;
    z-index: 1;
  }

  .title,
  .text {
    position: relative;
    z-index: 2;
    pointer-events: none;
  }
}
```

Из чего складывается приём:

- ссылка пустая и растянута по карточке (`position: absolute; inset: 0`) — нажатие засчитывается
  на всей площади, а не только на тексте заголовка;
- карточке нужен `position: relative`: без него ссылка растянется по ближайшему позиционированному
  предку выше и перекроет чужую область;
- содержимое лежит слоем выше (`z-index: 2`) и не перехватывает указатель
  (`pointer-events: none`) — иначе попадание в букву заголовка не считается попаданием в ссылку;
- ссылке даётся доступное имя (`aria-label` или визуально скрытый текст): у пустой ссылки его нет,
  и для программы чтения с экрана она остаётся безымянной;
- вложенный интерактивный элемент — вторая ссылка, кнопка «в избранное» — возвращает себе
  `pointer-events: auto` и слой выше остальных, иначе он недоступен.

Ограничение приёма: содержимое с `pointer-events: none` не выделяется мышью. Если выделение текста
нужно, ссылкой делается заголовок, а карточка остаётся некликабельной.

### Состояние — на родителе

Наведение, нажатие и фокус описываются на карточке, а не на вложенной ссылке:

```scss
.card:hover { … }        /* да */
.card .link:hover { … }  /* нет */
```

Указатель физически находится над карточкой; ссылка лежит под содержимым и своего наведения может
не получить. Правило действует и в общем виде: состояние описывается на том элементе, границы
которого видит пользователь, а не на том, который технически принимает событие. Фокус с клавиатуры
попадает на ссылку, поэтому видимое состояние вешается через `:focus-within` на карточку.

---

## Стили

### Вложенность повторяет структуру, а не углубляет её

```scss
.panel {          // контейнер компонента
  .title { … }
  .list { … }
}

.card { … }       // переиспользуемый элемент — верхний уровень, со своей вложенностью
```

Собственные элементы компонента вкладываются в его класс — по файлу видно, из чего компонент
состоит. Класс, который применяется и в других местах, остаётся на верхнем уровне: вложенный
селектор работает только внутри родителя, и при переносе его придётся копировать.

Глубина ограничивается тремя уровнями. Селектор из пяти имён описывает не элемент, а конкретное
дерево разметки и перестаёт работать при первой же его перестановке.

### Свойства со значением по умолчанию не пишутся

Строка остаётся в файле, только если меняет поведение по сравнению с тем, что уже действует, —
значением браузера по умолчанию или общим сбросом. Что удаляется чаще всего:

| Строка | Когда лишняя |
|---|---|
| `font-style: normal` | почти всегда: наклонный шрифт нужно включить, а не выключить |
| `margin: 0`, `padding: 0` | если сброс уже снял их у этого элемента |
| `text-decoration: none` | на всём, кроме `<a>` |
| `background: transparent` | у элемента без собственного фона |
| `position: static`, `display: block` | значения по умолчанию для блочного элемента |

Проверка: удалить строку и посмотреть на элемент. Ничего не изменилось — строка была лишней.
Причина правила не в объёме файла: строка, которая ничего не меняет, читается как принятое
решение, и при правке её обходят стороной, опасаясь сломать вид.

### Слои

`z-index` задаётся парой соседних значений внутри одного контекста наложения: подложка — `1`,
содержимое — `2`. Значения вида `999` означают, что нужный контекст не найден; лечится это
добавлением `position: relative` общему родителю, а не увеличением числа.

### Значения

Цвет, отступ, радиус, тень и типографика приходят из токенов темы (BMFP, правила дизайна). В файле
компонента остаются только числа, описывающие его собственную геометрию: соотношение сторон, число
колонок, доля ширины.

---

## Именование

| Что | Правило | Пример |
|---|---|---|
| Класс | роль внутри компонента | `title`, `list`, `link` — не `boldRed` |
| Класс в модуле стилей | camelCase | `cardTitle` доступен как `styles.cardTitle`; `card-title` требует `styles['card-title']` |
| Компонент | что это, а не откуда взялось | `InfoCard` — не `SectionInfoLink` |
| Тип свойств | имя компонента + `Props` | `CardProps` |
| Свойство-обработчик | по событию снаружи | `onSelect` — не `handleDelete` |

Компонент, названный по месту первого использования, не переносится в другое место без
переименования — а именно перенос и означает, что он оказался переиспользуемым.

Свойства компонента типизируются явно. Необязательное свойство получает значение по умолчанию в
сигнатуре, а не проверку в теле: значение по умолчанию видно вместе с типом.

---

## Комментарии

Комментарий объясняет причину или ограничение, а не действие:

```scss
/* указатель снят: иначе клик по заголовку не доходит до ссылки под ним */
pointer-events: none;
```

Комментарий `/* стили заголовка */` над `.title` пересказывает следующую строку и устаревает
раньше неё.

Декоративные разделители (`/* ===== Карточки ===== */`) не заводятся: порядок и вложенность уже
показывают структуру, а рамка из символов расходится с содержимым при первой перестановке блоков.

Закомментированный код в файле не остаётся — прежние варианты хранит история репозитория. Пометка
`TODO` сопровождается условием снятия; без условия она остаётся навсегда.

---

## Чеклист перед ревью

- [ ] нет обёрток, у которых нет задачи;
- [ ] интерактивная область — один элемент, у него есть доступное имя;
- [ ] содержимое поверх ссылки не перехватывает указатель;
- [ ] наведение и фокус описаны на видимой границе элемента;
- [ ] вложенность стилей не глубже трёх уровней, переиспользуемый класс — на верхнем уровне;
- [ ] нет свойств, совпадающих со значением по умолчанию;
- [ ] `z-index` — соседние значения в одном контексте наложения;
- [ ] цвета и отступы взяты из токенов;
- [ ] комментарии отвечают на «почему»; закомментированного кода нет.

---

Документ: http://docs.gitaspen.ru/development/process/testing

# Тестирование

Что покрывать тестами, какими именно и где они лежат. Документ общий для фронта и бэка: слои
разные, принцип один — тест проверяет наблюдаемое поведение через границу, а не внутреннее
устройство.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| код разложен по слоям (BMFP / BMBP) | что и как покрывать, где хранить тесты | сборка релиза, где прогон тестов — условие выпуска |

Что этот документ оставляет следующему: набор проверок, который запускается одной командой и даёт
однозначный ответ «можно выпускать». Тесты, которые надо запускать вручную и толковать глазами,
эту роль не выполняют.

---

## Что проверять на каждом слое

Границы слоёв задают, что считать поведением, а что — устройством.

**Бэкенд:**

| Слой | Что проверяется | Что подменяется |
|---|---|---|
| `core` (логика, сценарии) | правила предметной области: расчёты, переходы состояний, отказы | хранилище и внешние клиенты |
| `api` | контракт: коды ответов, форма конверта, проверка входных данных, права | сценарии слоя `core` |
| `infrastructure` | запросы к хранилищу и разбор ответов внешних систем | сама внешняя система |
| сквозной | путь запроса целиком на поднятых зависимостях | ничего |

**Фронт:**

| Слой | Что проверяется | Что подменяется |
|---|---|---|
| `domain` (состояния, сервисы) | расчёты, правила формы, последовательность вызовов | клиенты `infrastructure` |
| `infrastructure/clients` | разбор конверта, проверка ответа схемой, обработка ошибки | сеть |
| `boundary` | что видит и делает пользователь: отображение, ввод, реакция на ошибку | сервисы `domain` |

Логика проверяется там, где она живёт. Если правило можно проверить только через интерфейс, оно
оказалось не в том слое.

---

## Виды тестов и их доля

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

Основной объём — изолированные и на связку. Сквозных немного: они покрывают главные сценарии
(вход, ключевая операция, оплата), а не каждую ветку.

Соотношение выводится из цели: набор должен отрабатывать достаточно быстро, чтобы его запускали
перед каждым изменением. Набор, который идёт полчаса, перестают запускать.

---

## Правила

**Тест проверяет поведение, а не устройство.** Обращение — через публичную границу единицы. Тест,
знающий о приватных полях и порядке внутренних вызовов, ломается при любом переписывании, которое
ничего не изменило снаружи.

**Тест независим.** Порядок запуска не влияет на результат: каждый готовит своё состояние и убирает
за собой. Тесты, работающие только в определённом порядке, скрывают ошибки и мешают параллельному
запуску.

**Отказ теста называет причину.** Из сообщения должно быть понятно, что сломалось, без чтения кода
теста. Проверка `assert result` менее полезна, чем проверка конкретного ожидаемого значения.

**Один тест — одно утверждение о поведении.** Не «проверить весь сценарий заказа», а «отказ при
недостатке средств не создаёт заказ».

**Ошибка сначала воспроизводится тестом.** Тест, падающий до исправления и проходящий после, — это
одновременно доказательство исправления и защита от повторения.

**Внешние системы подменяются на своей границе.** Подменяется клиент, а не сетевой вызов внутри
него: тогда тест не зависит от того, какой библиотекой сделан запрос.

---

## Данные для тестов

Состояние готовится самим тестом, а не общей базой, наполненной заранее: общий набор данных со
временем становится непонятным, и никто не знает, что сломается при его правке.

Значения — осмысленные для проверки: если тест про отказ при недостатке средств, в нём видно, что
средств не хватает. Случайные данные скрывают причину отказа.

Для тестов на связку хранилище поднимается на время прогона и удаляется после. Общая база на всех
разработчиков даёт зависимость от порядка и от чужих данных.

---

## Что проверять обязательно

Помимо основного пути:

- **границы входных данных**: пусто, максимум, неверный тип, лишние поля;
- **отказы соседей**: недоступное хранилище, таймаут внешней системы, ошибка очереди;
- **права**: чужой ресурс не отдаётся и не изменяется — проверка на каждой ручке, а не на одной;
- **повторный вызов** операций, которые обязаны быть идемпотентными;
- **форма ответа**: конверт и коды ошибок — по ним ветвится фронт.

Проверка прав в тестах особенно важна: ошибка здесь не проявляется в обычной работе и обнаруживается
только тем, кто ищет её намеренно.

---

## Где лежат тесты

Рядом с кодом, который проверяют, — так они видны при чтении и не забываются при переносе.
Сквозные тесты, требующие поднятой среды, — отдельным каталогом на уровне продукта.

Тесты переиспользуемой единицы принадлежат ей: потребитель не обязан проверять её работу.

---

## Запуск

Один прогон одной командой, без ручных предварительных шагов. Результат — код возврата: ноль или
нет. Прогон входит в сборку релиза; сборка при непрошедших тестах не выпускается.

**Проверка:**

```bash
<команда прогона>              # ожидается: все тесты прошли, код возврата 0
echo $?                        # ожидается: 0
```

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| тесты падают через раз | зависимость от порядка или общего состояния | каждый тест готовит и убирает своё |
| после безобидного переписывания упало полсотни тестов | тесты знают внутреннее устройство | проверять через публичную границу |
| набор перестали запускать | идёт слишком долго | перенести объём с сквозных на изолированные |
| тесты зелёные, на рабочей системе ошибка | не покрыты стыки — схема, запросы, права | добавить тесты на связку с настоящим хранилищем |
| непонятно, что сломалось | проверка без конкретного ожидаемого значения | сравнивать с ожидаемым, а не проверять истинность |
| ошибка вернулась после исправления | не был написан воспроизводящий тест | сначала тест, потом исправление |

---

Документ: http://docs.gitaspen.ru/development/operations/docker-install

# Docker на сервере: установка, настройка, обслуживание

Инструкция доводит чистый сервер до состояния «Docker и Compose работают, приложения публикуются
безопасно, диск не забивается логами». Отдельным разделом — полная переустановка, когда прежняя
установка сломана.

**Что нужно до начала:** сервер Ubuntu 22.04/24.04 или Debian 12 с доступом `sudo`.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| чистый сервер | Docker, Compose, правила публикации портов, ротация логов | запуск приложения и его шлюза, затем домен и TLS |

Что этот документ оставляет следующему звену: рабочий Docker и правило публикации портов —
приложение и его шлюз публикуются **на loopback**, поэтому наружу их выставляет только nginx хоста,
завершающий TLS. Нарушение этого правила делает настройку домена бессмысленной: сервис остаётся
доступен по `http://example.com:8000` в обход шлюза и шифрования.

---

## Шаг 1. Установка

Ставится официальный репозиторий Docker: в стандартных репозиториях дистрибутива версия отстаёт, а
пакет `docker-compose` из них — устаревшая отдельная утилита вместо плагина `docker compose`.

```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

sudo systemctl enable --now docker
```

Для Debian замените `ubuntu` на `debian` в обеих ссылках.

**Проверка:**

```bash
docker --version           # ожидается версия Docker Engine
docker compose version     # ожидается версия Compose v2 (команда из двух слов, без дефиса)
sudo docker run --rm hello-world
```

**Ожидается:** контейнер `hello-world` печатает приветствие и завершается. Ошибка
`permission denied` при обращении к сокету — см. шаг 2.

---

## Шаг 2. Запуск без sudo

Добавьте пользователя в группу `docker`, чтобы не писать `sudo` перед каждой командой:

```bash
sudo usermod -aG docker "$USER"
newgrp docker      # применить в текущей сессии; иначе перелогиньтесь
```

**Проверка:**

```bash
docker ps          # ожидается таблица контейнеров без ошибки прав
```

Членство в группе `docker` равносильно правам root на этой машине: через сокет можно запустить
контейнер, смонтировав корень файловой системы. Добавляйте в группу только тех, кому такой уровень
доступа положен.

---

## Шаг 3. Ротация логов

По умолчанию Docker пишет логи контейнеров без ограничения размера, и на долго работающем сервере
они занимают весь диск. Ограничение задаётся один раз для всех контейнеров:

```bash
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
EOF

sudo systemctl restart docker
```

**Проверка:**

```bash
docker info --format '{{.LoggingDriver}}'    # ожидается: json-file
```

Настройка действует на контейнеры, созданные после перезапуска демона; уже запущенные нужно
пересоздать.

---

## Шаг 4. Публикация портов

Порт публикуется с явным адресом. Это главное правило эксплуатации: от него зависит, доступен ли
сервис в обход шлюза и TLS.

```yaml
services:
  gateway:
    image: nginx:alpine
    ports:
      - "127.0.0.1:8000:80"     # доступно только с этой машины
      # - "8000:80"             # доступно всему интернету
```

Запись без адреса (`"8000:80"`) публикует порт на всех интерфейсах. **Правила `ufw` при этом не
действуют:** Docker добавляет свои правила в `iptables` в цепочку, которая обрабатывается раньше,
поэтому порт остаётся открытым снаружи, даже если `ufw` его запрещает.

**Проверка — с другой машины:**

```bash
curl -m 5 -I http://example.com:8000     # ожидается таймаут или отказ соединения
```

Если ответ пришёл — сервис доступен напрямую, в обход шлюза. Исправьте публикацию и повторите.

**Проверка на сервере:**

```bash
sudo ss -ltnp | grep :8000               # ожидается адрес 127.0.0.1:8000
docker compose ps --format 'table {{.Service}}\t{{.Ports}}'
```

### `ports` и `expose` — разные вещи

| Директива | Что делает | Кому доступен порт |
|---|---|---|
| `ports: ["127.0.0.1:8000:80"]` | публикует порт на хосте | процессам этой машины |
| `ports: ["8000:80"]` | публикует порт на всех интерфейсах | всему интернету |
| `expose: ["8000"]` | ничего не публикует, только объявляет порт | контейнерам в общих сетях |

Внутри одной сети Compose сервисы обращаются друг к другу **по имени сервиса и внутреннему порту** —
публикация для этого не нужна. Публикуют только то, что должно быть видно с хоста.

Отсюда правило для микросервиса: он **не публикует порты вовсе**, у него `expose`. Наружу его
выводит шлюз, стоящий перед ним. Публикация порта микросервиса — распространённая ошибка: она
открывает обход шлюза со всеми проверками, которые на шлюзе настроены.

---

## Шаг 5. Автозапуск после перезагрузки

Сам Docker включён (`systemctl enable`), но контейнеры поднимутся, только если это задано политикой
перезапуска:

```yaml
services:
  gateway:
    restart: unless-stopped
```

**Проверка:**

```bash
sudo reboot
# после подключения:
docker compose ps      # ожидается: сервисы в состоянии running
```

---

## Обслуживание

**Место на диске.** Неиспользуемые образы и слои копятся при каждой сборке:

```bash
docker system df                    # что сколько занимает
docker image prune -f               # удалить образы без тегов
docker builder prune -f             # очистить кэш сборки
```

**Осторожно с полной очисткой.** Команда ниже удаляет и тома, то есть данные баз, которые в них
лежат:

```bash
docker system prune -af --volumes   # удаляет ВСЁ неиспользуемое, включая тома с данными
```

Выполняйте её только на машине, где нет данных, которые нужно сохранить, и после проверки
`docker volume ls`.

**Логи контейнера:**

```bash
docker compose logs -f --tail=100 gateway
```

---

## Переустановка со сносом

Применяется, когда установка сломана и чинить её дороже, чем поставить заново. **Все контейнеры,
образы и тома с данными будут удалены** — сделайте резервные копии томов до начала.

```bash
docker ps -aq | xargs -r docker stop
docker system prune -af --volumes

sudo systemctl stop docker docker.socket
sudo apt-get purge -y docker docker-engine docker.io containerd runc \
  docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo rm -rf /var/lib/docker /var/lib/containerd /etc/docker
sudo rm -f /etc/apt/sources.list.d/docker.list /etc/apt/keyrings/docker.gpg
sudo apt-get autoremove -y
```

Далее — с шага 1.

---

## Изменение параметров демона

Параметры демона задаются в `/etc/docker/daemon.json` (шаг 3). Если нужно поменять параметры
**запуска** службы, не редактируйте `/lib/systemd/system/docker.service`: этот файл принадлежит
пакету и будет заменён при первом же обновлении Docker. Используйте drop-in:

```bash
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/override.conf >/dev/null <<'EOF'
[Service]
LimitNOFILE=1048576
EOF

sudo systemctl daemon-reload
sudo systemctl restart docker
```

**Проверка:**

```bash
systemctl show docker -p LimitNOFILE     # ожидается заданное значение
```

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| `permission denied` на `/var/run/docker.sock` | пользователь не в группе `docker` | шаг 2, затем перелогиниться |
| `docker-compose: command not found` | ожидается старая отдельная утилита | использовать `docker compose` (плагин v2) |
| порт доступен снаружи, хотя `ufw` его запрещает | порт опубликован без адреса | указать `127.0.0.1:` в публикации (шаг 4) |
| контейнеры не поднялись после перезагрузки | нет политики перезапуска | добавить `restart: unless-stopped` |
| «нет места на устройстве» при сборке | накопился кэш и старые образы | `docker system df`, затем `image prune` и `builder prune` |
| логи занимают десятки гигабайт | не ограничен размер логов | шаг 3 и пересоздать контейнеры |
| после обновления Docker пропали правки службы | правился файл пакета | перенести правки в drop-in `override.conf` |
| контейнер не видит соседа по имени | сервисы в разных сетях Compose | привести к одной сети либо обращаться по имени сервиса внутри общей сети |

---

Документ: http://docs.gitaspen.ru/development/operations/tls-certificates

# HTTPS для домена: nginx + Let's Encrypt

Инструкция доводит сервер от «есть чистая машина и домен» до «сайт открывается по HTTPS, сертификат
продлевается сам». Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом: если
результат другой — переходите к разделу «Типичные отказы», не выполняя следующий шаг.

**Что нужно до начала:** сервер Ubuntu 22.04/24.04 или Debian 12 с доступом `sudo`, домен и право
менять его DNS-записи, приложение, которое будет за nginx (или его пока нет — тогда проверочная
страница).

## Место в цепочке: два nginx с разными ролями

В контуре два nginx, и их роли не пересекаются. Смешивать их не нужно — от разделения зависит,
получится ли выкатывать приложение, не трогая сертификаты.

```
интернет → nginx на хосте            → 127.0.0.1:8000 → nginx-шлюз приложения → микросервисы
           TLS, домен, редирект                         маршруты, CORS, health
           (этот документ)                              (документ о шлюзе)
```

**nginx на хосте** (этот документ) знает только про домен и сертификат: принимает 443, завершает
TLS, перенаправляет HTTP на HTTPS и отдаёт весь трафик одним `proxy_pass` на локальный порт. Про
внутреннее устройство приложения он не знает ничего — при добавлении микросервиса его конфиг не
меняется.

**nginx-шлюз приложения** запускается вместе с приложением (обычно в его `docker-compose`),
слушает порт 80 внутри контейнера и публикуется на loopback хоста. Он разводит запросы по
микросервисам, держит CORS и служебные ручки. Сертификатов он не касается: наружу он не смотрит.

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| сервер с Docker; приложение и его шлюз запущены, шлюз опубликован на `127.0.0.1:8000` | домен, TLS, перенаправление, автопродление | выкат новых версий приложения за уже работающим доменом |

Что предыдущее звено обязано обеспечить: шлюз опубликован **на loopback**, а не на всех
интерфейсах (шаг 5), и отвечает на служебной ручке — по ней проверяется связка.

Что этот документ оставляет следующему: постоянный публичный адрес и завершение TLS на хосте.
Дальше приложение работает по HTTP внутри машины, сертификатов не касается, а его выкат не задевает
конфигурацию домена.

**Порядок принципиален.** Сертификата ещё нет, поэтому сначала поднимается HTTP-блок, затем
выпускается сертификат, и только потом добавляется HTTPS-блок. Если написать блок `listen 443` со
ссылкой на файлы сертификата до выпуска, nginx не запустится: файлов нет.

---

## Шаг 1. DNS: домен указывает на этот сервер

Узнайте адрес сервера и сверьте с записями домена. Проверять нужно **каждое** имя, которое войдёт в
сертификат: если хотя бы одно не резолвится, выпуск не пройдёт целиком.

```bash
curl -4 -s ifconfig.me; echo          # IP этого сервера
dig +short example.com
dig +short www.example.com            # только если www тоже нужен в сертификате
```

**Ожидается:** значения `dig` совпадают с IP сервера.

Записи DNS обновляются не мгновенно — время зависит от TTL. Если значения расходятся, дождитесь
обновления и повторите. Если `www` не нужен, просто не включайте его в команды ниже.

---

## Шаг 2. Пакеты и открытые порты

```bash
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx
```

Порт 80 нужен для проверки владения доменом, 443 — для самого сайта:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status
```

Если сервер за облачным фаерволом провайдера или за NAT — те же порты откройте и там.

**Проверка:**

```bash
curl -I http://example.com
```

**Ожидается:** любой HTTP-ответ со статусом (например, `200` или `404`) — значит, запрос дошёл до
nginx. Отказ соединения или таймаут означает, что порт закрыт или DNS ведёт не сюда.

---

## Шаг 3. HTTP-блок и каталог для проверки

Определите, как устроены конфиги в вашей установке:

```bash
ls -d /etc/nginx/sites-available 2>/dev/null || echo "используется conf.d"
```

- каталог есть (сборка Debian/Ubuntu) — файл кладётся в `/etc/nginx/sites-available/example.conf`
  и включается симлинком;
- каталога нет (пакеты nginx.org, Alpine) — файл кладётся в `/etc/nginx/conf.d/example.conf`,
  симлинк не нужен.

Создайте каталог для проверки владения и файл конфигурации:

```bash
sudo mkdir -p /var/www/acme

sudo tee /etc/nginx/sites-available/example.conf >/dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    # Каталог проверки владения доменом. Должен стоять ДО перенаправления на HTTPS,
    # иначе запрос центра сертификации уйдёт в редирект и проверка не пройдёт.
    location /.well-known/acme-challenge/ {
        root /var/www/acme;
    }

    location / {
        return 200 "ok\n";
        add_header Content-Type text/plain;
    }
}
EOF

sudo ln -sf /etc/nginx/sites-available/example.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```

`nginx -t` обязателен перед каждой перезагрузкой: `reload` с ошибочным конфигом оставит работать
старую конфигурацию, и расхождение между файлом и работающим сервером легко не заметить.

**Проверка** — запрос по домену доходит именно до этого блока, и каталог проверки отдаётся:

```bash
echo "test" | sudo tee /var/www/acme/.well-known/acme-challenge/probe >/dev/null 2>&1 \
  || sudo mkdir -p /var/www/acme/.well-known/acme-challenge && echo "test" \
  | sudo tee /var/www/acme/.well-known/acme-challenge/probe >/dev/null

curl -s http://example.com/                              # ожидается: ok
curl -s http://example.com/.well-known/acme-challenge/probe   # ожидается: test
```

**Ожидается:** `ok` и `test`. Если вместо этого приходит страница nginx по умолчанию — ваш блок не
подхватился (см. «Типичные отказы»).

После успешной проверки удалите пробный файл: `sudo rm /var/www/acme/.well-known/acme-challenge/probe`

---

## Шаг 4. Выпуск сертификата

Способ зависит от того, кому доверять правку конфигурации nginx.

| Способ | Когда применять | Кто правит конфиг |
|---|---|---|
| `--webroot` | конфиг ведёте сами (рекомендуется для шлюзов и нетиповых схем) | вы |
| `--nginx` | типовой сайт, автоматическая правка устраивает | certbot |
| DNS-01 | нужен wildcard `*.example.com` либо порт 80 недоступен снаружи | вы |

### Вариант A. `--webroot` (конфиг остаётся вашим)

```bash
sudo certbot certonly --webroot -w /var/www/acme \
  -d example.com -d www.example.com \
  -m admin@example.com --agree-tos -n
```

### Вариант B. `--nginx` (certbot настраивает сам)

```bash
sudo certbot --nginx -d example.com -d www.example.com \
  --redirect -m admin@example.com --agree-tos -n
```

В этом варианте certbot сам добавит блок `listen 443` и перенаправление — шаг 5 можно пропустить и
сразу перейти к проверке в шаге 6.

**Проверка после выпуска:**

```bash
sudo certbot certificates
sudo ls -l /etc/letsencrypt/live/example.com/
```

**Ожидается:** сертификат в списке со сроком около 90 дней и файлы `fullchain.pem` и `privkey.pem`.

---

## Шаг 5. HTTPS-блок

Выполняется **только после** успешного выпуска: блок ссылается на файлы сертификата, и без них
nginx не запустится.

Замените содержимое файла конфигурации:

```bash
sudo tee /etc/nginx/sites-available/example.conf >/dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ { root /var/www/acme; }
    location / { return 301 https://$host$request_uri; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;                      # nginx 1.25.1 и новее; в старых: listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;

    client_max_body_size 200M;     # поднимите, если принимаете крупные загрузки

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
EOF

sudo nginx -t && sudo systemctl reload nginx
```

### Что стоит за nginx хоста и почему только loopback

`proxy_pass` ведёт не в само приложение, а в **шлюз приложения** — второй nginx, запущенный вместе
с приложением. Дальше маршруты по микросервисам разводит он.

Порт шлюза должен быть доступен **лишь с самой машины** — на `127.0.0.1`. Если он опубликован на
`0.0.0.0`, клиент обращается к шлюзу напрямую по `http://example.com:8000`, минуя nginx хоста: без
TLS, с открытым незашифрованным трафиком и в обход всего, что настроено на домене.

```bash
sudo ss -ltnp | grep :8000
```

**Ожидается** адрес `127.0.0.1:8000`. Значение `0.0.0.0:8000` или `*:8000` означает, что порт
открыт на всех интерфейсах.

Как привязать к loopback, зависит от того, как запущено приложение:

```bash
uvicorn app:app --host 127.0.0.1 --port 8000     # Python/ASGI
node server.js                                    # в коде: app.listen(8000, '127.0.0.1')
```

В Docker публикация порта задаётся явным адресом:

```yaml
services:
  app:
    ports:
      - "127.0.0.1:8000:8000"     # доступно только с хоста
      # - "8000:8000"             # так порт открыт всему интернету
```

Запись `"8000:8000"` публикует порт на всех интерфейсах. Docker добавляет свои правила в `iptables`
раньше правил `ufw`, поэтому такой порт остаётся доступным снаружи, **даже если ufw его запрещает**.
Проверить фактическую доступность снаружи можно с другой машины:

```bash
curl -m 5 -I http://example.com:8000     # ожидается таймаут или отказ соединения
```

Если ответ пришёл — приложение доступно в обход nginx; исправьте привязку и повторите.

Когда nginx работает в контейнере, а приложение — на хосте, `127.0.0.1` внутри контейнера указывает
на сам контейнер. В этом случае в `proxy_pass` используют `host.docker.internal` (нужен
`extra_hosts: ["host.docker.internal:host-gateway"]`) либо помещают оба контейнера в одну сеть и
обращаются по имени сервиса.

**Проверка связки:**

```bash
curl -I http://127.0.0.1:8000     # ожидается ответ приложения
```

Если приложения пока нет, оставьте на время `location / { return 200 "ok\n"; }` вместо `proxy_pass`.

**Для WebSocket** добавьте в `location` две строки — без них соединение обрывается при обновлении
протокола:

```nginx
proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection "upgrade";
```

---

## Шаг 6. Проверка результата

```bash
curl -I https://example.com                                   # ожидается ответ приложения без ошибки TLS
curl -sI http://example.com | grep -i location                # ожидается: https://example.com/
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -issuer                        # срок и издатель
```

Последняя команда показывает сертификат, который сервер отдаёт фактически. Она отличает случай,
когда файлы обновились, но nginx продолжает работать со старым сертификатом в памяти.

---

## Шаг 7. Автоматическое продление

Сертификат действует 90 дней. Пакет certbot ставит системный таймер, который проверяет продление
дважды в сутки и обновляет сертификат, когда остаётся менее 30 дней.

```bash
systemctl status certbot.timer --no-pager     # ожидается: active
sudo certbot renew --dry-run                  # ожидается: simulated renewal ... success
```

`--dry-run` обращается к тестовому серверу и не расходует лимиты выпуска.

Работающий nginx не подхватывает новый файл сертификата сам — добавьте перезагрузку после
обновления:

```bash
sudo certbot renew --deploy-hook "systemctl reload nginx"
```

`--deploy-hook` срабатывает только при фактическом обновлении, а не при каждой проверке. Чтобы он
применялся и автоматическим продлением, впишите его в файл домена
`/etc/letsencrypt/renewal/example.com.conf` в секцию `[renewalparams]`:

```ini
renew_hook = systemctl reload nginx
```

**Отдельно про способ `--standalone`.** Он поднимает собственный веб-сервер на порту 80, который во
время продления занят nginx. Продление в этом случае падает при каждой попытке — до истечения
срока. Если сертификат выпускался так, переведите его на `--webroot`:

```bash
sudo certbot certonly --webroot -w /var/www/acme -d example.com --force-renewal
```

---

## Wildcard-сертификат

`*.example.com` выдаётся только методом DNS-01: владение подтверждается TXT-записью, проверка по
HTTP для него не принимается.

```bash
sudo certbot certonly --manual --preferred-challenges dns \
  -d "*.example.com" -d example.com \
  -m admin@example.com --agree-tos
```

Certbot попросит создать TXT-запись `_acme-challenge.example.com`. Дождитесь её распространения,
прежде чем подтверждать:

```bash
dig +short TXT _acme-challenge.example.com    # ожидается значение, которое показал certbot
```

Ручной способ требует повторять операцию при каждом продлении. Если у DNS-провайдера есть API,
поставьте плагин `python3-certbot-dns-*` — тогда продление станет автоматическим.

---

## Если nginx работает в контейнере

Каталог `/etc/letsencrypt` держат на хосте и монтируют внутрь только на чтение; выпуском и
продлением занимается certbot на хосте.

```yaml
services:
  nginx:
    image: nginx:alpine
    ports: ["80:80", "443:443"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - ./acme:/var/www/acme            # каталог проверки владения
```

```bash
sudo certbot certonly --webroot -w ./acme -d example.com
sudo certbot renew --deploy-hook "docker compose exec nginx nginx -s reload"
```

Такое разделение оставляет управление сертификатами на хосте и не требует пересобирать образ при
продлении.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| `curl` к домену — отказ соединения или таймаут | закрыт порт 80 либо DNS ведёт не на этот сервер | сверить `dig +short example.com` с `curl -4 ifconfig.me`, открыть 80/tcp в ufw и у провайдера |
| вместо своей страницы отдаётся страница nginx по умолчанию | конфиг не подключён или его перехватывает сайт по умолчанию | проверить симлинк в `sites-enabled`, при необходимости отключить `default`: `sudo rm /etc/nginx/sites-enabled/default` |
| `404` на `/.well-known/acme-challenge/...` | `location` проверки стоит после редиректа на HTTPS | поднять `location /.well-known/acme-challenge/` выше `location /` |
| выпуск падает, хотя основной домен резолвится | одно из имён в `-d` не имеет DNS-записи | убрать лишнее имя или добавить запись; проверить каждое имя отдельно |
| nginx не стартует: `cannot load certificate` | блок `listen 443` написан до выпуска сертификата | вернуть конфиг к HTTP-блоку шага 3, выпустить сертификат, затем добавить HTTPS-блок |
| сайт отвечает `502` | приложение не слушает адрес из `proxy_pass` | `sudo ss -ltnp \| grep :8000`, запустить приложение или исправить адрес |
| браузер показывает старый сертификат | nginx не перезагружен после продления | `sudo systemctl reload nginx`, добавить `renew_hook` |
| `too many certificates already issued` | достигнут недельный лимит выпуска на домен | дождаться окончания недельного окна; отлаживать через `--dry-run`, а не боевыми выпусками |
| продление падает только автоматически | сертификат выпущен способом `standalone` | перевыпустить через `--webroot` |

Журналы: `/var/log/letsencrypt/letsencrypt.log` — выпуск и продление;
`sudo journalctl -u nginx -e --no-pager` и `/var/log/nginx/error.log` — nginx.

---

## Снятие домена

```bash
sudo certbot delete --cert-name example.com
sudo rm /etc/nginx/sites-enabled/example.conf
sudo nginx -t && sudo systemctl reload nginx
```

Удаление не отзывает сертификат. Если закрытый ключ мог попасть в чужие руки, отзовите его до
удаления:

```bash
sudo certbot revoke --cert-path /etc/letsencrypt/live/example.com/fullchain.pem
```

---

Документ: http://docs.gitaspen.ru/development/operations/database

# База данных: постановка, схемы, миграции

Как довести хранилище от «базы нет» до состояния «сервисы работают со своими схемами, изменения
схемы выкатываются и откатываются». Документ описывает PostgreSQL; в нём два способа постановки —
своя база в контейнере рядом с сервисом и внешняя управляемая база у провайдера.

**Исходное состояние:** сервер с Docker, репозиторий сервиса, базы данных нет.

**Что нужно до начала:**

| Условие | Проверка | Ожидается |
|---|---|---|
| Docker и Compose работают | `docker compose version` | `Docker Compose version v2.…` |
| клиент `psql` основной версии, совпадающей с сервером | `psql --version` | `psql (PostgreSQL) 17.…` |
| каталог секретов не попадает в репозиторий | `git check-ignore -v .secrets/.env` | строка с правилом из `.gitignore` |
| место на диске данных | `df -h /var/lib/docker` | запас не меньше, чем на данные, журнал предзаписи и снимок |

Клиент можно не ставить на сервер: команды `psql` ниже выполняются внутри контейнера базы через
`docker compose exec`. Отдельный клиент нужен для внешней базы и для снятия копий — тогда его
основная версия должна совпадать с версией сервера.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| [Docker на сервере](./docker-install.md) и [сетевой контур](./network-topology.md) | постановка базы, пользователи, схема на сервис, миграции | [резервное копирование](./backup-and-restore.md), затем [выкат](./release-and-deploy.md) с применением миграций |

Что предыдущее звено обязано обеспечить: правило публикации портов. База не публикуется наружу
вообще — ни на все интерфейсы, ни «временно, чтобы посмотреть». Порт 5432, доступный из интернета,
делает бессмысленными и шлюз, и TLS, и разграничение прав.

Что этот документ оставляет следующему: базу с пользователем на каждый сервис, схемой и таблицей
версий; отдельного пользователя только на чтение — на нём строится снятие копий; и требование к
выкату — миграции применяет один исполнитель, а не каждая копия приложения.

Правило [BMBP](../architecture/BMBP.md) «изоляция по схемам БД» здесь получает точный вид: правило
вывода имени схемы, права и порядок создания.

---

## Два способа: своя база рядом и внешняя управляемая

| Признак | Своя в контейнере | Внешняя управляемая |
|---|---|---|
| кто обновляет версию, следит за диском | вы | провайдер |
| копии | настраиваются отдельно ([документ](./backup-and-restore.md)) | часть услуги; свои копии всё равно нужны — как независимая от провайдера |
| сеть | внутренняя сеть Compose, публикации нет | доступ по сети провайдера, обязательно шифрование |
| шифрование соединения | не требуется, если база и сервис в одной внутренней сети (`sslmode=disable`) | обязательно: `sslmode=verify-full` и корневой сертификат провайдера |
| список разрешённых адресов | не нужен: снаружи адреса нет | нужен: доступ открывается только адресам ваших серверов |
| восстановление на момент времени | нет, только из снимков | как правило есть |
| когда применим | один сервер, дев-контур, небольшой объём | несколько серверов, требование к доступности, нежелание обслуживать базу |

Различие в настройке подключения сводится к строке подключения и к тому, кто отвечает за
доступность. Всё остальное — пользователи, схемы, миграции — одинаково.

**Шифрование соединения.** Значения `sslmode` различаются не «сильнее/слабее», а тем, что
проверяется:

| Значение | Шифрование | Проверка сертификата | Проверка имени хоста |
|---|---|---|---|
| `disable` | нет | нет | нет |
| `require` | да | нет | нет |
| `verify-ca` | да | да | нет |
| `verify-full` | да | да | да |

`require` защищает от пассивного прослушивания, но не от подмены сервера: сертификат не
проверяется. Для внешней базы целевое значение — `verify-full` с файлом корневого сертификата
провайдера. `disable` допустим только там, где трафик не выходит за пределы внутренней сети одной
машины.

---

## Шаг 1. Поднять базу

### Своя база в контейнере

`compose.yml` сервиса (по [BMBP](../architecture/BMBP.md) минимум для запуска — сервис и его база):

```yaml
services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: dbadmin
      POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD}
      POSTGRES_INITDB_ARGS: --data-checksums
    command: >
      postgres
      -c shared_buffers=1GB
      -c effective_cache_size=3GB
      -c work_mem=16MB
      -c maintenance_work_mem=256MB
      -c max_connections=100
      -c log_min_duration_statement=500ms
    volumes:
      - db_data:/var/lib/postgresql/data
    shm_size: 256m
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dbadmin -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 20
    # ports не объявлены: база доступна только по имени `db` внутри сети Compose

volumes:
  db_data:
```

Разбор существенных мест:

- **Версия закреплена основным номером** (`17`, а не `latest`). Каталог данных привязан к основной
  версии: сервер отказывается стартовать на каталоге, созданном другой основной версией. Смена
  `17` на `18` в теге — не обновление, а отказ запуска (см. отказы). Чтобы пересборка давала тот
  же образ, тег дополняют цифровым отпечатком: `postgres:17-alpine@sha256:…`.
- **Том именованный.** Данные в томе переживают пересоздание контейнера; данные в слое контейнера
  исчезают вместе с ним. Путь монтирования берётся из образа, он менялся между версиями:
  `docker image inspect postgres:17-alpine -f '{{.Config.Env}}'` — переменная `PGDATA`.
- **`--data-checksums`** включает контрольные суммы страниц: тихая порча данных обнаруживается при
  чтении, а не через месяцы. Параметр действует только при инициализации кластера — на уже
  созданном каталоге его так не добавить.
- **`shm_size`.** По умолчанию контейнер получает 64 МБ `/dev/shm`; параллельные запросы используют
  разделяемую память и падают на нехватке.
- **`ports` отсутствуют.** Сервис обращается к базе по имени `db` внутри сети Compose. Если
  локальному инструменту нужен доступ, порт публикуется только на петлевой интерфейс и только в
  дев-контуре: `- "127.0.0.1:5433:5432"` (5433 — чтобы не конфликтовать с базой, установленной на
  хост).

Параметры памяти — отправная точка для сервера с 4 ГБ ОЗУ, дальше настраиваются по нагрузке:

| Параметр | Что задаёт | Ориентир |
|---|---|---|
| `shared_buffers` | собственный кеш страниц, занимает память сразу | ≈25% ОЗУ |
| `effective_cache_size` | оценка кеша для планировщика; память не занимает | ≈75% ОЗУ |
| `work_mem` | память на одну сортировку или хеш **внутри** запроса | 16 МБ |
| `maintenance_work_mem` | построение индексов, `VACUUM` | 256 МБ |
| `max_connections` | предел числа соединений | 100 |

`work_mem` умножается: в одном запросе может быть несколько сортировок, и каждый параллельный
исполнитель берёт свою порцию. Худший случай — `max_connections × work_mem × число узлов`, поэтому
значение держат небольшим, а параллелизм ограничивают пулом приложения, а не базой.

Запуск и проверка:

```bash
docker compose up -d db
docker compose exec db pg_isready -U dbadmin -d appdb
```

**Ожидается:** `/var/run/postgresql:5432 - accepting connections`.

```bash
docker compose exec db psql -U dbadmin -d appdb -c "SELECT version()"
```

**Ожидается:** строка, начинающаяся с `PostgreSQL 17.` — основная версия совпадает с закреплённой в
теге образа.

### Внешняя управляемая база

Контейнера с базой нет; вместо шага выше выполняется:

1. создать базу у провайдера, зафиксировать основную версию — она должна совпадать с версией
   клиента, которым будут сниматься копии;
2. в списке разрешённых адресов открыть доступ только адресам своих серверов;
3. скачать корневой сертификат провайдера и положить его рядом с секретами сервиса
   (`.secrets/db-root.crt`), смонтировав в контейнер сервиса только на чтение.

**Проверка** (нужен OpenSSL 1.1.1 или новее):

```bash
openssl s_client -starttls postgres -connect db.example.com:5432 \
  -CAfile .secrets/db-root.crt </dev/null 2>/dev/null | grep 'Verify return code'
```

**Ожидается:** `Verify return code: 0 (ok)`. Другой код означает, что сертификат не соответствует
файлу или имя хоста не совпадает — подключение с `verify-full` не заработает.

---

## Шаг 2. Пользователи и права

Права выдаются от нуля вверх, а не «всё, потом урежем». Роли ровно три вида:

| Роль | Кто | Права |
|---|---|---|
| административная | человек при постановке и обслуживании | создаёт базу, роли и схемы |
| сервисная, по одной на сервис | приложение | подключение к базе, полные права в своей схеме |
| читающая, одна на установку | снятие резервных копий | подключение и чтение всех данных |

Отдельный пользователь на сервис нужен по двум причинам: сервис не может дотянуться до чужих
данных, а в `pg_stat_activity` видно, чьи соединения заняли пул.

Пароли берутся из файла окружения и не попадают ни в SQL-файлы, ни в репозиторий. `psql`
подставляет их через переменные, поэтому в тексте команд значений нет:

```bash
docker compose exec -T db psql -U dbadmin -d appdb -v ON_ERROR_STOP=1 \
  -v orders_pw="$ORDERS_DB_PASSWORD" -v backup_pw="$BACKUP_DB_PASSWORD" <<'SQL'
-- Подключаться к базе может не любая роль, а только названные.
REVOKE CONNECT ON DATABASE appdb FROM PUBLIC;

-- Общая схема public не используется под таблицы сервисов.
-- Начиная с PostgreSQL 15 право CREATE у PUBLIC отозвано по умолчанию.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

-- Пользователь сервиса orders.
CREATE ROLE orders_app LOGIN PASSWORD :'orders_pw';
GRANT CONNECT ON DATABASE appdb TO orders_app;

-- Пользователь для снятия копий: чтение всех данных, ничего больше.
CREATE ROLE backup_reader LOGIN PASSWORD :'backup_pw';
GRANT CONNECT ON DATABASE appdb TO backup_reader;
GRANT pg_read_all_data TO backup_reader;
SQL
```

`pg_read_all_data` (PostgreSQL 14 и новее) даёт чтение всех таблиц и право входа во все схемы. Это
именно то, что нужно `pg_dump`, и это избавляет от выдачи прав заново при каждой новой таблице.
Право на запись у этой роли отсутствует.

**Проверка:**

```bash
docker compose exec db psql -U dbadmin -d appdb -c "\du"
```

**Ожидается:** в списке есть `orders_app` и `backup_reader`, у обоих в колонке атрибутов нет
`Superuser` и `Create DB`.

---

## Шаг 3. Схема на сервис

### Правило вывода имени

Имя схемы получается из имени сервиса механически, без исключений:

1. привести имя сервиса к нижнему регистру;
2. каждый символ вне `[a-z0-9_]` заменить на `_`;
3. если первая позиция — цифра, добавить в начало `s_` (идентификатор не может начинаться с цифры);
4. обрезать до 63 байт — предел длины идентификатора PostgreSQL.

| Имя сервиса | Схема |
|---|---|
| `orders` | `orders` |
| `order-history` | `order_history` |
| `Accounts Service` | `accounts_service` |
| `2fa` | `s_2fa` |

Префикс `pg_` зарезервирован системой: сервис с таким именем переименовывается, схема с таким
именем не создаётся.

Полученное значение **один раз записывается в конфигурацию сервиса явным полем** (`DB_SCHEMA`) и
дальше не вычисляется заново. Причина: переименование сервиса не должно молча увести его на пустую
схему. При смене имени сервиса значение `DB_SCHEMA` остаётся прежним; схемы не переименовывают.

### Создание и видимость

Схему создаёт администратор вместе с пользователем — тогда сервису не нужно право `CREATE` на базу,
которого у него быть не должно (и которого управляемые базы обычно и не дают):

```sql
CREATE SCHEMA orders AUTHORIZATION orders_app;

-- Все сессии сервиса работают в своей схеме; public остаётся для расширений.
ALTER ROLE orders_app SET search_path = orders, public;
```

Владелец схемы — сервисная роль: миграции создают в ней таблицы без дополнительных выдач прав.

Права на чужие схемы не выдаются, и этого достаточно: без `USAGE` роль не может обратиться ни к
одному объекту чужой схемы. Ограничение касается доступа к данным, а не к именам — системный
каталог PostgreSQL читается всеми, поэтому `\dn` покажет и чужие схемы. Скрыть их названия
средствами базы нельзя; изоляция здесь означает «не прочитать и не изменить».

**Проверка:**

```bash
docker compose exec db psql -U dbadmin -d appdb -c "\dn"
```

**Ожидается:** строка `orders | orders_app`.

```bash
docker compose exec db psql -U orders_app -d appdb \
  -c "SHOW search_path" -c "SELECT current_user, current_schema()"
```

**Ожидается:** `orders, public`, затем `orders_app | orders`.

```bash
docker compose exec db psql -U orders_app -d appdb -c "SELECT 1 FROM accounts.customers LIMIT 1"
```

**Ожидается:** `ERROR: permission denied for schema accounts`. Успешный ответ означает, что права
выданы шире, чем нужно, — разбирать до перехода к следующему шагу.

---

## Шаг 4. Миграции

### Требования к инструменту

Инструмент берётся из стека сервиса (в [нашем стеке](../architecture/BMBP.md) это Alembic; в других
— встроенный мигратор библиотеки доступа к базе). Независимо от выбора он обязан:

1. читать файлы миграций из каталога `migrations/` в репозитории сервиса, рядом с кодом, но вне
   кода приложения;
2. задавать порядок применения явно — номером в имени файла или ссылкой на предыдущую ревизию;
3. хранить отметки о применённых миграциях в таблице **внутри схемы сервиса**, а не в `public`:
   иначе два сервиса в одной базе перезапишут историю друг друга;
4. применять каждый файл в транзакции и брать блокировку на время применения.

Пункт 3 в инструментах задаётся отдельно: у одних это параметр таблицы версий и её схемы, у других
— достаточно `search_path`, выставленного при подключении. Проверяется по факту: после первой
миграции таблица версий должна оказаться в `orders`, а не в `public`.

### Файлы и именование

Базовая форма — нумерованные SQL-файлы:

```
migrations/
├── 0001_init.sql
├── 0002_api_tokens.sql
├── 0003_orders_status_index.sql
└── 0004_drop_customer_name.sql
```

Правила:

- номер — четыре цифры с ведущими нулями, увеличивается на единицу, без пропусков;
- после номера — краткое описание в нижнем регистре через подчёркивания;
- порядок применения — по возрастанию номера;
- **применённый файл не редактируется и не перенумеровывается.** Отметка в базе ссылается на номер;
  правка файла означает, что у разных установок под одним номером разное содержимое. Исправление —
  всегда новый файл;
- одна миграция — одно связное изменение. Файл, меняющий пять несвязанных таблиц, невозможно
  откатить частично.

Если миграции генерирует ORM, номер заменяется цепочкой ревизий: имя файла генерируется
инструментом, порядок задаётся ссылкой на предыдущую ревизию. Тогда добавляется ещё одно правило —
**одна голова**: две ветки миграций, слитые из разных веток кода, дают неопределённый порядок.
Проверяется командой инструмента, показывающей текущие головы; ожидается ровно одна.

### Первое применение

```bash
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction -f migrations/0001_init.sql
```

`--single-transaction` вместе с `ON_ERROR_STOP=1` даёт нужное свойство: ошибка в середине файла
откатывает файл целиком. В PostgreSQL изменения схемы транзакционны, поэтому «половина таблиц
создалась» — не следствие природы базы, а следствие применения без транзакции.

Исключение — операторы, которые в транзакции выполняться не могут: `CREATE INDEX CONCURRENTLY`,
`DROP INDEX CONCURRENTLY`, `VACUUM`. Каждый такой оператор выносится в **отдельный** файл миграции,
помеченный как невыполняемый в транзакции, и применяется без `--single-transaction`.

**Проверка:**

```bash
docker compose exec db psql -U orders_app -d appdb -c "\dt"
docker compose exec db psql -U orders_app -d appdb \
  -c "SELECT * FROM orders.schema_migrations ORDER BY 1 DESC LIMIT 3"
```

**Ожидается:** `\dt` перечисляет таблицы со схемой `orders` в колонке Schema; в таблице версий —
номера применённых миграций, последний совпадает с последним файлом в `migrations/`. Имя таблицы
версий задаётся инструментом (`schema_migrations`, `alembic_version` и т. п.) — важно, что она в
схеме сервиса.

### Кто применяет

Миграции применяет **один исполнитель**: отдельный шаг выката, а не старт приложения. Если каждая
копия применяет миграции при запуске, две копии, поднятые одновременно, войдут в них
одновременно — таблица версий получит конфликт по уникальному ключу либо оператор упадёт на «объект
уже существует».

Когда старт приложения всё же применяет миграции (например, единственная копия в дев-контуре),
перед проверкой таблицы версий берётся рекомендательная блокировка — вторая копия ждёт, а не
выполняет то же самое:

```sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('orders.migrations')::bigint);
-- проверка таблицы версий и применение недостающих файлов
COMMIT;
```

Часть инструментов берёт такую блокировку сама — тогда добавлять её не нужно; проверяется по
документации инструмента.

---

## Шаг 5. Подключение из приложения

**Строка подключения** приходит одной переменной окружения; пароль в неё подставляется из файла
секретов, в образ не попадает:

```
# своя база в той же сети Compose
postgresql://orders_app:${ORDERS_DB_PASSWORD}@db:5432/appdb?sslmode=disable

# внешняя управляемая база
postgresql://orders_app:${ORDERS_DB_PASSWORD}@db.example.com:5432/appdb?sslmode=verify-full&sslrootcert=/app/.secrets/db-root.crt
```

**Пул.** Соединение с базой — дорогой ресурс: у сервера их конечное число, а установка нового
требует рукопожатия (для шифрованного соединения — заметно дороже, в наблюдавшихся случаях доли
секунды). Отсюда параметры:

| Параметр | Смысл | Ориентир |
|---|---|---|
| верхний размер пула | сколько соединений одна копия держит максимум | 10–20 |
| нижний размер пула | сколько соединений держатся тёплыми | 2–4 для внешней базы, 0 для локальной |
| время простоя соединения | когда лишнее соединение закрывается | 300 с |
| время жизни соединения | принудительная переустановка | 1800 с |
| проверка перед выдачей | отсев соединений, оборванных перезапуском базы | включена |
| имя приложения | подпись в `pg_stat_activity` | имя сервиса |

Сумма пулов всех копий плюс миграционный шаг плюс копирование должна помещаться в
`max_connections` с запасом: часть слотов сервер резервирует за административными подключениями.
Правило: `max_connections ≥ число копий × верхний размер пула + 10`.

Нижний размер пула для внешней базы отличается от локальной осознанно: посредники на пути рвут
простаивающие соединения, и без тёплых соединений первый запрос после паузы платит за
переустановку шифрованного канала.

**Таймауты** задаются в слое инфраструктуры, рядом с подключением
([BMBP](../architecture/BMBP.md)), и частично — на роли, чтобы действовали независимо от кода:

```sql
ALTER ROLE orders_app SET statement_timeout = '10s';
ALTER ROLE orders_app SET lock_timeout = '3s';
ALTER ROLE orders_app SET idle_in_transaction_session_timeout = '30s';
```

- `statement_timeout` — предел на один запрос; без него один тяжёлый запрос держит соединение
  неограниченно;
- `lock_timeout` — предел ожидания блокировки; без него миграция, ждущая чужую транзакцию,
  выстраивает за собой очередь из запросов;
- `idle_in_transaction_session_timeout` — обрыв сессии, открывшей транзакцию и забывшей её закрыть;
  такие сессии удерживают блокировки и мешают очистке;
- таймаут получения соединения из пула задаётся в приложении: запрос, которому соединение не
  досталось, должен закончиться ошибкой сразу, а не ждать.

**Поведение при недоступности базы:**

- при старте сервис не завершается с ошибкой, а повторяет подключение с растущей паузой и отвечает
  «не готов» на служебной ручке. Выкат при этом не переключит трафик
  ([релиз](./release-and-deploy.md)), а перезапуск базы не потребует ручного перезапуска сервисов;
- в работе запрос завершается ошибкой в конверте `{ status: error }`, а не зависает;
- повторяются только идемпотентные операции, с ограниченным числом попыток и растущей паузой;
- миграционный шаг при недоступной базе завершается с ошибкой и останавливает выкат — это
  правильное поведение: применять миграции «когда получится» нельзя.

**Проверка:**

```bash
curl -sf http://127.0.0.1:8000/ready
docker compose exec db psql -U dbadmin -d appdb -c \
  "SELECT application_name, state, count(*) FROM pg_stat_activity WHERE datname='appdb' GROUP BY 1,2"
```

**Ожидается:** служебная ручка отвечает успехом; в списке соединений видно имя сервиса, число
соединений не превышает верхний размер пула, строк в состоянии `idle in transaction` нет.

---

## Шаг 6. Проверка закрытости

Выполняется на сервере после запуска — это тот шаг, пропуск которого обнаруживается уже по чужим
запросам в логах:

```bash
ss -ltn | grep -E ':(5432|5433)'
docker compose port db 5432
```

**Ожидается:** первая команда не выводит ничего либо выводит только `127.0.0.1:5433`; вторая
сообщает, что публикации нет. Строка вида `0.0.0.0:5432` означает, что база доступна из интернета —
убрать публикацию и считать пароли скомпрометированными.

С другой машины:

```bash
nc -vz example.com 5432
```

**Ожидается:** отказ в соединении или истечение времени ожидания.

---

## Изменение схемы дальше

Порядок выката описан в [релизе](./release-and-deploy.md): миграции применяются после подъёма
неактивной копии и до переключения входа. База при этом одна на обе копии, поэтому **каждая
миграция обязана быть совместимой с предыдущей версией кода**.

Что делает изменение с таблицей:

| Изменение | Что происходит | Как выполнять |
|---|---|---|
| добавить таблицу | существующие не затронуты | обычной миграцией |
| добавить необязательную колонку | правка метаданных | обычной миграцией |
| добавить колонку со значением по умолчанию | с PostgreSQL 11 без переписывания таблицы | обычной миграцией |
| добавить индекс на большой таблице | `CREATE INDEX` блокирует запись на время построения | `CONCURRENTLY`, отдельным файлом, вне транзакции |
| `SET NOT NULL`, сужение типа | проверка или переписывание под блокировкой | вторым выкатом, после заполнения значений |
| удалить колонку или таблицу | старый код перестаёт работать | вторым выкатом |
| переименовать | старый код перестаёт работать сразу | не переименовывать: добавить новое, перейти, удалить старое |

### Необратимое изменение — два выката

Пример: текстовое поле `customer_name` заменяется ссылкой `customer_id`.

**Выкат 1** — только расширение, `0007_add_customer_id.sql`:

```sql
ALTER TABLE orders ADD COLUMN customer_id BIGINT REFERENCES customers(id);
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
```

Код этого выката пишет оба поля, читает старое. Существующие строки заполняются отдельным шагом:
на большой таблице один `UPDATE` на все строки удерживает блокировки и раздувает таблицу, поэтому
заполнение идёт пачками по несколько тысяч строк, вне миграции.

**Между выкатами** проверяется, что старого кода не осталось: все копии обновлены, а поле
заполнено у всех строк.

**Выкат 2** — сужение, `0009_drop_customer_name.sql`:

```sql
DELETE FROM orders WHERE customer_id IS NULL;   -- либо заполнить; иначе SET NOT NULL не пройдёт
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;
ALTER TABLE orders DROP COLUMN customer_name;
```

Строка с `DELETE` — не формальность: это те записи, до которых заполнение не дошло. К моменту
второго выката с ними нужно осознанное решение, иначе `SET NOT NULL` остановит выкат.

### Откат миграции

Основной способ — **исправление вперёд**: новая миграция, отменяющая изменение. Обратные скрипты
редко проверяются и не возвращают данные, которые уже удалены разрушающим изменением.

| Ситуация | Что делать |
|---|---|
| изменение аддитивное (добавили колонку, индекс, таблицу) | новая миграция, снимающая добавленное; либо `downgrade` инструмента, если он есть и проверен |
| изменение удалило колонку или таблицу | схему вернуть можно, данные — нет: [восстановление из копии](./backup-and-restore.md) |
| миграция не применилась целиком | см. отказы ниже: снять незавершённые объекты, сделать файл повторно применимым, применить заново |

Отметку в таблице версий правят только вместе с фактическим состоянием схемы: запись без
изменения — это установка, где следующая миграция упадёт на неожиданной схеме.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| контейнер базы не стартует, в логе `database files are incompatible with server` | тег образа сменили на другую основную версию, том остался от прежней | вернуть прежний тег; обновление основной версии — снятие снимка, чистый том, восстановление снимка |
| `pg_restore` не читает файл, жалуется на версию формата | снимок снят клиентом новее сервера | снимать и восстанавливать клиентом той же основной версии ([копии](./backup-and-restore.md)) |
| после восстановления часть объектов отсутствует | снимок снят пользователем без прав на чтение всех схем | снимать под ролью с `pg_read_all_data`, контролировать размер файла |
| миграция применена наполовину: объект есть, отметки в таблице версий нет | файл применён без транзакции, либо в нём есть `CONCURRENTLY`, который в транзакции работать не может | снять созданное вручную (в том числе `DROP INDEX` для индекса в состоянии `INVALID`), разнести операторы по файлам, применить заново |
| `ERROR: could not extend file …: No space left on device`, база останавливается | кончилось место; часто из-за роста журнала предзаписи при застрявшем слоте репликации или сломанной архивации | освободить место, проверить размер каталога журнала и неиспользуемые слоты, расширить диск; на будущее — оповещение по свободному месту |
| запросы падают по таймауту получения соединения, база не загружена | пул исчерпан: соединения не возвращаются (незакрытые транзакции) или пул меньше реальной параллельности | посмотреть `pg_stat_activity` по состояниям; включить `idle_in_transaction_session_timeout`; поднять пул, если `max_connections` позволяет; при большом числе копий — общий пул-посредник |
| `FATAL: sorry, too many clients already` | суммарные пулы копий превысили `max_connections` | пересчитать по правилу «копии × пул + 10»; уменьшить пул или увеличить предел |
| при одновременном выкате: конфликт уникального ключа в таблице версий либо «объект уже существует» | миграции применяют две копии сразу | применять миграции отдельным шагом выката; при старте из приложения — рекомендательная блокировка |
| `ERROR: permission denied for database appdb` при старте, хотя схема существует | сервис выполняет `CREATE SCHEMA IF NOT EXISTS`, а право `CREATE` на базу проверяется до проверки существования схемы | создавать схему администратором и убрать создание из сервиса, либо выдать сервису `CREATE` на базу |
| `relation "…" does not exist`, хотя таблица создана | не выставлен `search_path`, либо миграция создала объекты в `public` | закрепить `search_path` на роли; перенести объекты в схему сервиса |
| `no pg_hba.conf entry …, SSL off` | внешняя база требует шифрования, приложение подключается с `sslmode=disable` | `sslmode=verify-full` и корневой сертификат |
| `certificate verify failed` при `verify-full` | файл сертификата не смонтирован в контейнер или путь в строке подключения указывает мимо | смонтировать `.secrets` на чтение, сверить путь `sslrootcert` |
| `could not resize shared memory segment` на параллельных запросах | в контейнере 64 МБ `/dev/shm` | задать `shm_size` |
| база доступна с чужого адреса | опубликован порт или открыт список разрешённых адресов | убрать публикацию, сузить список, сменить пароли |

---

## Откат и снятие

**Вернуть схему на предыдущую версию.** Применить обратную миграцию (или `downgrade` инструмента,
если изменение было аддитивным) и убедиться, что отметка в таблице версий соответствует
фактическому состоянию:

```bash
docker compose exec db psql -U orders_app -d appdb \
  -c "SELECT * FROM orders.schema_migrations ORDER BY 1 DESC LIMIT 1" -c "\dt"
```

Если изменение уничтожило данные, схемой дело не решается: нужно
[восстановление из копии](./backup-and-restore.md), в том числе частичное — по одной таблице.

**Снять схему сервиса** (дев-контур, повторное разворачивание с нуля):

```sql
DROP SCHEMA orders CASCADE;
```

Удаляются таблицы, ключи, индексы и таблица версий — следующий запуск применит миграции с нуля.
Соседние схемы не затрагиваются: в этом и смысл разделения.

**Снять базу целиком:**

```bash
docker compose down          # контейнер убран, том с данными остался
docker compose down -v       # том удалён вместе с данными — необратимо
```

Что при этом не удаляется: файлы миграций в репозитории, резервные копии во внешнем хранилище,
файлы секретов вне репозитория. Именно на них опирается разворачивание заново, поэтому проверять
наличие копии нужно **до** удаления тома, а не после.

---

Документ: http://docs.gitaspen.ru/development/operations/server-setup

# Базовая настройка сервера: от выдачи машины до готовности к Docker

Инструкция доводит только что выданную машину до состояния, с которого начинается установка Docker:
система обновлена, работа идёт под отдельным пользователем, вход — только по ключу, межсетевой экран
включён, время синхронизировано, журналы ограничены по размеру. Каждый шаг заканчивается проверкой с
однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы», не
выполняя следующий шаг.

**Что нужно до начала:**

- машина с Ubuntu 22.04/24.04 LTS, выданная провайдером (следующий документ цепочки допускает также
  Debian 12; отличия отмечены по месту);
- доступ по SSH под пользователем с правами администратора — `root` или пользователь в группе `sudo`;
- аварийная консоль провайдера (VNC/KVM в панели управления) и известный пароль администратора;
- ключевая пара на рабочей машине, с которой вы подключаетесь.

Проверки предусловий — на сервере, в текущем сеансе:

```bash
. /etc/os-release && echo "$ID $VERSION_ID"     # ожидается: ubuntu 22.04 или ubuntu 24.04
sudo -v                                          # ожидается: команда завершается без сообщений
```

На рабочей машине:

```bash
ls -l ~/.ssh/id_ed25519.pub                      # ожидается: файл существует
ssh-keygen -t ed25519                            # только если файла нет
```

Закрытый ключ (`id_ed25519`, без `.pub`) остаётся на рабочей машине и никуда не копируется. На сервер
уходит только открытая часть.

Аварийную консоль проверьте **до** изменений: откройте её в панели провайдера и убедитесь, что видно
приглашение `login:`. Консоль работает в обход SSH и межсетевого экрана — это единственный путь назад,
если доступ по сети потерян. Консоль без известного пароля бесполезна: вход в неё идёт по паролю, а не
по ключу.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| провайдер выдал машину с образом Ubuntu LTS и доступом администратора | обновления, рабочий пользователь, вход по ключу, межсетевой экран, время, автообновления безопасности, ограничение журналов | [установка Docker](./docker-install.md) и дальше по цепочке разворачивания |

Что предыдущее звено обязано обеспечить: доступ администратора по SSH и работающая аварийная консоль.
Без консоли шаги 3 и 4 выполнять нельзя — при ошибке в них доступ по сети теряется, и восстановить его
будет нечем.

Что этот документ оставляет следующему звену:

- пользователя с `sudo` — именно его [документ о Docker](./docker-install.md) добавляет в группу
  `docker`;
- включённый межсетевой экран с разрешённым SSH. Порты 80 и 443 открывает
  [документ о HTTPS](./tls-certificates.md) — до появления домена они не нужны;
- синхронизированное время: без него проверка сертификата и сопоставление журналов разных машин дают
  неверный результат;
- ограниченные по размеру системные журналы. Логи контейнеров ограничиваются отдельно, в
  [документе о Docker](./docker-install.md), — они пишутся драйвером Docker, а не journald.

Отдельно: включённый межсетевой экран **не** защищает порты, опубликованные контейнерами. Docker
добавляет свои правила в `iptables` в цепочку, которая обрабатывается раньше правил `ufw`, поэтому порт,
опубликованный без адреса, доступен снаружи вопреки запрету. Публикацией на loopback занимается
следующий документ; здесь важно не считать `ufw` достаточной мерой для контейнеров.

**Порядок принципиален** в двух местах:

1. Вход по ключу проверяется **до** запрета парольного входа. Обратный порядок оставляет машину без
   единого работающего способа подключиться.
2. Правило, разрешающее SSH, добавляется **до** `ufw enable`. По умолчанию экран запрещает входящие
   соединения, поэтому включение без такого правила отрезает новые подключения.

---

## Шаг 1. Обновление системы и базовый набор инструментов

Свежий образ провайдера отстаёт от репозитория на срок с момента его сборки, включая исправления
безопасности.

```bash
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg lsb-release ufw unattended-upgrades rsync git
```

Набор минимальный и подобран под следующие шаги: `ca-certificates`, `curl`, `gnupg`, `lsb-release`
нужны для подключения репозитория Docker; `ufw` — шаг 4; `unattended-upgrades` — шаг 6; `rsync`
используется при резервном копировании.

**Проверка:**

```bash
apt list --upgradable        # ожидается: только строка "Listing... Done"
```

Если обновилось ядро или системные библиотеки, нужна перезагрузка:

```bash
[ -f /var/run/reboot-required ] && cat /var/run/reboot-required
sudo reboot
```

**Ожидается:** файл `/var/run/reboot-required` отсутствует (команда ничего не печатает) либо машина
перезагружена и файл исчез.

---

## Шаг 2. Отдельный пользователь для работы

Вход администратором не именной: под `root` действия всех людей выглядят одинаково, а любая ошибка
выполняется с полными правами без промежуточного подтверждения. Отдельный пользователь с `sudo` даёт
именные записи в журнале и явную границу между обычными действиями и действиями с правами root.

Дальше пользователь называется `deploy`; имя может быть любым, но оно должно совпадать во всех
командах ниже.

```bash
sudo adduser --gecos "" deploy      # спросит пароль: задайте и сохраните в менеджере паролей
sudo usermod -aG sudo deploy
```

Пароль нужен для `sudo` и для входа через аварийную консоль провайдера. По сети он работать не будет:
парольный вход отключается на шаге 3. Пользователь, созданный с `--disabled-password`, не сможет
выполнить `sudo` — команде нечего будет проверить.

Скопируйте открытый ключ. Содержимое `~/.ssh/id_ed25519.pub` с рабочей машины вставляется одной
строкой:

```bash
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh

sudo tee /home/deploy/.ssh/authorized_keys >/dev/null <<'EOF'
ssh-ed25519 AAAA…сюда содержимое id_ed25519.pub целиком, одной строкой…
EOF

sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
```

Тот же результат с рабочей машины, пока парольный вход ещё разрешён:

```bash
ssh-copy-id -i ~/.ssh/id_ed25519.pub -o User=deploy example.com
```

Здесь и далее `example.com` — адрес вашего сервера. Форма `ssh -l имя адрес` равнозначна привычной
записи с `@`.

**Проверка:**

```bash
id deploy                                                    # ожидается: в списке групп есть sudo
stat -c '%a %U %n' /home/deploy /home/deploy/.ssh /home/deploy/.ssh/authorized_keys
```

**Ожидается:** каталог `.ssh` — `700 deploy`, файл `authorized_keys` — `600 deploy`, домашний каталог —
`750` или `755` и владелец `deploy`. Права шире (запись для группы или для всех) — sshd откажется
использовать ключ и потребует пароль.

Проверка входа выполняется **вторым сеансом**, не закрывая текущий:

```bash
ssh -l deploy example.com     # ожидается: вход без запроса пароля
sudo whoami                   # в новом сеансе; ожидается: root (спросит пароль пользователя)
```

---

## Шаг 3. Вход только по ключу, без администратора

Настройка кладётся в отдельный файл, а не в `/etc/ssh/sshd_config`: основной файл принадлежит пакету и
может быть заменён при обновлении. В Ubuntu 22.04 и новее первой строкой основного файла идёт
`Include /etc/ssh/sshd_config.d/*.conf`.

```bash
grep -n '^Include' /etc/ssh/sshd_config     # ожидается: строка Include ... sshd_config.d/*.conf
```

Имя файла начинается с малого числа не случайно. Файлы читаются в алфавитном порядке, а для каждого
параметра sshd применяет **первое** встреченное значение. Образы провайдеров часто содержат
`50-cloud-init.conf` с `PasswordAuthentication yes`; файл с префиксом `10-` читается раньше и
определяет итоговое значение.

```bash
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF

sudo sshd -t                      # проверка синтаксиса; молчание означает, что ошибок нет
sudo systemctl reload ssh         # служба может называться sshd — это её псевдоним
```

`sshd -t` обязателен перед перезагрузкой: с ошибочным файлом служба не поднимется, а обнаружится это
только при следующем подключении.

В системах, где sshd запускается по сокету (Ubuntu 22.10 и новее), служба может быть неактивна, и
`reload` сообщит, что перезагружать нечего. Конфигурацию в этом случае читает каждое новое соединение
при старте, отдельного действия не требуется.

**Проверка — эффективные значения, а не содержимое файла:**

```bash
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication)'
```

**Ожидается:**

```
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
pubkeyauthentication yes
```

`sshd -T` показывает конфигурацию после разбора всех включённых файлов. Это единственный способ
отличить «параметр записан» от «параметр действует»: значение из `50-cloud-init.conf` в самом файле не
видно.

### Приём с двумя сеансами

Изменения sshd применяются только к **новым** соединениям — уже открытый сеанс продолжает работать,
даже если новая конфигурация запрещает вход. На этом строится защита от блокировки самого себя:

1. Первый сеанс (тот, в котором вы правили конфигурацию) **не закрывать** до конца проверки.
2. В другом окне терминала подключиться заново: `ssh -l deploy example.com`.
3. Если второй вход прошёл и `sudo whoami` вернул `root` — закрыть первый сеанс.
4. Если второй вход не прошёл — чинить в первом, который ещё открыт. Причину показывает
   `sudo journalctl -u ssh -e --no-pager` на сервере и запуск клиента с `-v`.

Закрытый первый сеанс до успешной проверки — самая частая причина потери доступа к серверу.
Восстановление в этом случае идёт через аварийную консоль (см. «Откат»).

**Дополнительные проверки — что запреты действительно работают:**

```bash
ssh -l deploy -o PubkeyAuthentication=no -o PreferredAuthentications=password example.com
ssh -l root example.com
```

**Ожидается:** обе команды завершаются с `Permission denied (publickey)`. Запрос пароля вместо этого
означает, что парольный вход остался разрешён.

---

## Шаг 4. Межсетевой экран

Правила задаются до включения. Политика по умолчанию — запрет входящих: разрешено только то, что
перечислено явно.

```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH            # профиль соответствует порту 22/tcp
```

Профиль `OpenSSH` открывает порт 22. Если sshd слушает другой порт, разрешать нужно его — иначе
включение экрана обрежет доступ:

```bash
sudo sshd -T | grep -i '^port'    # фактический порт sshd
```

Только после того как правило для SSH добавлено:

```bash
sudo ufw enable                   # спросит подтверждение; ответ y
```

Команда предупреждает о возможном разрыве соединений. Уже установленный сеанс, как правило,
переживает включение: `ufw` пропускает соединения в состоянии ESTABLISHED. Новые подключения без
правила для SSH будут отброшены — доступ потеряется при первом же переподключении.

**Проверка:**

```bash
sudo ufw status verbose
```

**Ожидается:**

```
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
```

`Status: inactive` означает, что экран не включён. Отсутствие строки с портом SSH при активном
статусе — состояние, в котором следующее подключение не пройдёт; добавьте правило немедленно, не
закрывая текущий сеанс.

Вторая проверка — новым соединением, при открытом текущем: `ssh -l deploy example.com` должен
подключиться.

**Ограничение частоты подключений** (необязательно) отбрасывает адрес, открывший более шести
соединений за 30 секунд:

```bash
sudo ufw limit OpenSSH
```

Правило снижает объём записей о переборе паролей в журнале. При работе, где сеансы открываются пачками
(например, параллельные команды по SSH), оно может отбросить и ваши собственные подключения.

**Если доступ к серверу идёт через частную сеть**, SSH можно ограничить её диапазоном:

```bash
sudo ufw allow from 10.0.0.0/8 to any port 22 proto tcp
```

Такое правило отрезает вход из любой другой сети, включая ту, из которой вы работаете сейчас.
Добавляйте его, только когда подключение через частную сеть уже проверено вторым сеансом.

Облачный межсетевой экран провайдера — отдельный слой поверх `ufw`. Если он включён, те же порты
открываются и в панели управления: правило на сервере не отменяет запрет у провайдера.

---

## Шаг 5. Часы и часовой пояс

Расхождение часов ломает то, что опирается на время, а не на порядок событий: проверку срока действия
сертификата (`not yet valid` при верном сертификате), сроки жизни токенов, одноразовые коды, а также
сопоставление журналов нескольких машин при разборе отказа.

```bash
timedatectl
```

**Ожидается:** `System clock synchronized: yes` и `NTP service: active`.

Часовой пояс на сервере — UTC: журналы разных машин сравниваются без пересчёта, а переход на летнее
время не создаёт в отметках времени пропусков и повторов.

```bash
sudo timedatectl set-timezone UTC
```

Если синхронизация выключена:

```bash
sudo timedatectl set-ntp true
```

Если служба синхронизации отсутствует или нужна более устойчивая, ставится `chrony`:

```bash
sudo apt install -y chrony
chronyc tracking
```

**Ожидается:** `Leap status : Normal` и малое значение в строке `System time` (доли секунды).

`chrony` заменяет `systemd-timesyncd`: обе службы синхронизируют часы, и одновременно работать они не
должны — пакет отключает встроенную службу при установке. Держать нужно одну.

**Проверка:**

```bash
timedatectl | grep -E 'Time zone|System clock|NTP service'
date -u
```

**Ожидается:** пояс `UTC`, `System clock synchronized: yes`, дата и время совпадают с фактическими.

---

## Шаг 6. Автоматические обновления безопасности

Пакет `unattended-upgrades` установлен на шаге 1. Периодический запуск включается отдельным файлом:

```bash
sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
```

Что именно ставится, задано в `/etc/apt/apt.conf.d/50unattended-upgrades`. По умолчанию включён только
источник `${distro_id}:${distro_codename}-security` — обновления безопасности. Добавление `-updates`
расширяет набор до обычных обновлений; на сервере это увеличивает и объём изменений, происходящих без
участия человека.

**Проверка:**

```bash
sudo unattended-upgrade --dry-run --debug | tail -n 20
systemctl list-timers 'apt-daily*' --no-pager
```

**Ожидается:** в выводе первой команды — список пакетов к обновлению либо строка о том, что
обновляемых пакетов нет; во второй — таймеры `apt-daily.timer` и `apt-daily-upgrade.timer` с
назначенным временем следующего запуска.

Журнал работы: `/var/log/unattended-upgrades/unattended-upgrades.log`.

**Перезагрузка после обновления ядра** не выполняется сама. Её можно включить, дописав в
`/etc/apt/apt.conf.d/50unattended-upgrades`:

```
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
```

Перезагрузка остановит и приложения. Включать её имеет смысл, когда сервисы поднимаются сами — для
контейнеров это политика `restart: unless-stopped` из [документа о Docker](./docker-install.md). Иначе
перезагрузку делают вручную, ориентируясь на `/var/run/reboot-required`.

---

## Шаг 7. Подкачка, если памяти мало

Подкачка нужна, когда памяти 1–2 ГБ или когда процессы завершаются ядром при нехватке памяти. Она не
ускоряет работу: страницы на диске читаются в разы медленнее, чем из памяти. Подкачка меняет
аварийное завершение процесса на замедление — при постоянной нехватке памяти правильное решение
другое, увеличить память.

```bash
free -h                # сколько памяти и есть ли подкачка
swapon --show          # пустой вывод — подкачки нет
```

Если провайдер уже выделил раздел подкачки (`swapon --show` не пуст), шаг пропускается.

```bash
sudo fallocate -l 2G /swapfile          # если ФС не поддерживает: sudo dd if=/dev/zero of=/swapfile bs=1M count=2048
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```

Распространённый ориентир объёма при памяти до 2 ГБ — столько же, сколько памяти. Точное значение
зависит от нагрузки; больший файл занимает место на диске, но не ускоряет работу.

Параметры ядра:

```bash
sudo tee /etc/sysctl.d/99-swap.conf >/dev/null <<'EOF'
vm.swappiness=10
vm.vfs_cache_pressure=50
EOF

sudo sysctl --system
```

`vm.swappiness` задаёт, насколько охотно ядро вытесняет страницы на диск: значение по умолчанию 60,
значение 10 откладывает вытеснение до реальной нехватки. `vm.vfs_cache_pressure` ниже 100 сохраняет
кэш метаданных файловой системы дольше.

**Проверка:**

```bash
swapon --show                       # ожидается строка /swapfile с указанным размером
free -h                             # ожидается ненулевая строка Swap
cat /proc/sys/vm/swappiness         # ожидается: 10
```

Запись в `/etc/fstab` проверяется без перезагрузки — иначе ошибка обнаружится только при следующем
старте:

```bash
sudo swapoff /swapfile && sudo swapon -a && swapon --show
```

**Ожидается:** `/swapfile` снова в списке. Пустой вывод означает ошибку в строке `/etc/fstab`.

---

## Шаг 8. Ограничение размера журналов

Журнал systemd по умолчанию занимает до 10% файловой системы. На небольшом диске это заметная доля,
которая расходуется незаметно и обнаруживается как «нет места на устройстве» при следующей сборке.

```bash
journalctl --disk-usage         # сколько занято сейчас
```

```bash
sudo mkdir -p /etc/systemd/journald.conf.d
sudo tee /etc/systemd/journald.conf.d/10-size.conf >/dev/null <<'EOF'
[Journal]
Storage=persistent
SystemMaxUse=500M
SystemMaxFileSize=50M
MaxRetentionSec=1month
EOF

sudo systemctl restart systemd-journald
```

`Storage=persistent` сохраняет журнал между перезагрузками: без этого записи о причинах отказа
пропадают ровно тогда, когда нужны — после перезапуска машины.

**Проверка:**

```bash
journalctl --disk-usage
```

**Ожидается:** объём не превышает заданный предел. Уже накопленное сверх лимита удаляется по мере
ротации; освободить место сразу:

```bash
sudo journalctl --vacuum-size=200M
sudo journalctl --vacuum-time=14d
```

Файлы в `/var/log`, которые пишут не через journald, обслуживает `logrotate` — пакеты приносят свои
правила при установке:

```bash
systemctl status logrotate.timer --no-pager           # ожидается: active
sudo du -xh --max-depth=1 /var/log | sort -h | tail -n 5
```

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

Логи контейнеров сюда не входят: их пишет драйвер Docker, и ограничиваются они в
[документе о Docker](./docker-install.md).

---

## Итоговая проверка состояния

Один прогон перед переходом к установке Docker:

```bash
. /etc/os-release && echo "$PRETTY_NAME"
id deploy
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication)'
sudo ufw status verbose | head -n 4
timedatectl | grep -E 'Time zone|System clock'
systemctl list-timers 'apt-daily*' --no-pager | head -n 3
swapon --show
journalctl --disk-usage
df -h /
```

**Ожидается:** Ubuntu LTS; пользователь `deploy` в группе `sudo`; `permitrootlogin no` и
`passwordauthentication no`; `Status: active` с разрешённым SSH; пояс UTC и синхронизированные часы;
назначенные таймеры обновлений; подкачка (если настраивалась) и объём журнала в пределах лимита;
свободное место на корневом разделе.

Дальше — [установка Docker](./docker-install.md).

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| `Permission denied (publickey)` под рабочим пользователем | ключ не установлен либо права на `~/.ssh` шире положенных | `stat -c '%a %U' /home/deploy/.ssh /home/deploy/.ssh/authorized_keys` — ожидается `700` и `600`; в журнале `sudo journalctl -u ssh -e` — `Authentication refused: bad ownership or modes` |
| второй сеанс не подключается, первый ещё открыт | ошибка в конфигурации sshd | не закрывая первый: `sudo sshd -T`, `sudo journalctl -u ssh -e --no-pager`; при неясности — удалить `10-hardening.conf` и повторить настройку |
| после `ufw enable` новые подключения отбрасываются | правило для SSH не добавлено до включения | войти через консоль провайдера, `sudo ufw disable`, добавить `sudo ufw allow OpenSSH`, включить снова |
| SSH на нестандартном порту, экран включён — доступа нет | разрешён профиль `OpenSSH` (порт 22), а sshd слушает другой порт | через консоль: `sudo sshd -T \| grep -i '^port'`, затем `sudo ufw allow <порт>/tcp` |
| порт контейнера доступен снаружи, хотя `ufw` его запрещает | правила Docker в `iptables` обрабатываются раньше правил `ufw` | публиковать порт с адресом `127.0.0.1` — [документ о Docker](./docker-install.md), публикация портов |
| `sudo` отказывает: пароль не подходит | пользователь создан с `--disabled-password` | `sudo passwd deploy` от администратора |
| доступ потерян полностью, консоль пускает | сеть закрыта экраном или sshd | `sudo ufw disable`, `sudo rm /etc/ssh/sshd_config.d/10-hardening.conf`, `sudo systemctl restart ssh` — далее по разделу «Откат» |
| выпуск или проверка сертификата падает с `not yet valid` либо `expired` при верном сроке | часы разошлись | `timedatectl`, `sudo timedatectl set-ntp true`, при необходимости `chrony` (шаг 5) |
| записи журналов разных машин не сходятся по времени | разные часовые пояса или нет синхронизации | привести все машины к UTC и включить синхронизацию (шаг 5) |
| «нет места на устройстве», приложения занимают мало | журналы заняли диск | `journalctl --disk-usage`, `sudo journalctl --vacuum-size=200M`, `sudo du -xh --max-depth=1 /var/log \| sort -h \| tail` |
| обновления безопасности не ставятся | не создан `20auto-upgrades` либо таймеры выключены | шаг 6, проверить `systemctl list-timers 'apt-daily*'` |
| после включения подкачки сервер стал заметно медленнее | памяти не хватает постоянно, работа идёт с диска | `free -h` под нагрузкой; подкачка не заменяет память — увеличить память или снизить нагрузку |
| подкачка пропала после перезагрузки | нет записи в `/etc/fstab` или в ней опечатка | проверить `sudo swapoff /swapfile && sudo swapon -a` (шаг 7) |

---

## Откат

Настройки снимаются по отдельности — возвращать всё сразу не требуется.

**Вернуть парольный вход и вход администратором.** Достаточно удалить файл настройки; параметры
вернутся к значениям из основного конфигурационного файла и файлов образа:

```bash
sudo rm /etc/ssh/sshd_config.d/10-hardening.conf
sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -i '^passwordauthentication'     # ожидается: yes
```

Чтобы вернуть только парольный вход, оставив запрет для администратора, замените содержимое файла на
`PermitRootLogin no` и перезагрузите службу.

**Отключить межсетевой экран.** Правила при этом сохраняются и вернутся при следующем включении:

```bash
sudo ufw disable
sudo ufw status         # ожидается: Status: inactive
```

Полный сброс правил — `sudo ufw reset`; прежние наборы сохраняются рядом в `/etc/ufw` с отметкой
времени.

**Если доступ по сети потерян.** Порядок восстановления:

1. Открыть консоль в панели провайдера и войти под администратором или под `deploy` по паролю. Консоль
   работает в обход SSH и `ufw`.
2. Снять то, что закрыло доступ: `sudo ufw disable`, при необходимости
   `sudo rm /etc/ssh/sshd_config.d/10-hardening.conf` и `sudo systemctl restart ssh`.
3. Подключиться по SSH и повторить настройку, не закрывая сеанс до проверки вторым сеансом.

Вставка из буфера обмена в консоли провайдера работает не всегда — длинные строки приходится набирать
вручную. Это ещё одна причина проверять ключ до запрета пароля, а не после.

**Если пароль администратора неизвестен и консоль не пускает**, остаётся режим восстановления
провайдера: машина загружается со служебного образа, корневой раздел монтируется как обычный каталог.
В смонтированном разделе удаляется `etc/ssh/sshd_config.d/10-hardening.conf`, а в `etc/ufw/ufw.conf`
значение `ENABLED` меняется на `no`. После обычной загрузки доступ по паролю и без экрана
восстанавливается.

**Снять подкачку:**

```bash
sudo swapoff /swapfile
sudo sed -i '\#^/swapfile #d' /etc/fstab
sudo rm /swapfile
swapon --show           # ожидается: пустой вывод
```

**Отключить автоматические обновления:** заменить в `/etc/apt/apt.conf.d/20auto-upgrades` значения на
`"0"`. Уже установленные обновления не откатываются.

Что при откате не удаляется: созданный пользователь и его домашний каталог с ключом, установленные
пакеты, ограничения журналов и настройки времени. Пользователя удаляют отдельно —
`sudo deluser --remove-home deploy`, и только после того, как проверен другой рабочий способ входа.

---

Документ: http://docs.gitaspen.ru/development/operations/logs

# Журналы: формат, сбор, хранение

Документ доводит установку от «сервисы что-то пишут в вывод контейнера» до «записи всех сервисов
лежат в одном месте, ищутся по идентификатору запроса, хранятся ограниченный срок и не содержат
секретов».

Это вторая половина наблюдения. Первая — [метрики и оповещения](./observability.md): они отвечают,
**что** не так и **когда** началось, журналы — **что именно** произошло в конкретном случае.
Инструменты журналов ставятся в тот же каталог и в тот же файл `compose.yml`, что и набор метрик,
поэтому документы выполняются подряд.

**Что нужно до начала:** выполнен документ о метриках — есть каталог `/opt/app/observability/`,
работает Grafana, объявлена сеть `obs_net`.

```bash
cd /opt/app/observability
docker compose ps
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/api/health
```

**Ожидается:** `prometheus` и `grafana` в состоянии `running` и код `200`.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| набор наблюдения поднят, метрики снимаются, Grafana открывается | формат записи, сбор из контейнеров, хранение и срок, поиск | разбор конкретного отказа: от всплеска на графике к записи о нём |

Что предыдущее звено обязано обеспечить: ограничение размера журналов Docker (`max-size`,
`max-file` в `/etc/docker/daemon.json`, см. [Docker](./docker-install.md)). Без него журналы
занимают весь диск раньше, чем до них дойдёт сборщик.

Что этот документ оставляет следующему: единая точка поиска и сквозной идентификатор запроса.
Разбор отказа (симптом → слой → причина) описан в [документе о метриках](./observability.md); здесь
даётся то, чем выполняется его третий шаг.

---

## Формат записи

**Структурой, а не строкой.** Запись — набор полей, а не предложение: так по ней можно искать и
считать.

```json
{"level":"error","msg":"payment declined","request_id":"7f3c…","user_id":42,"provider_code":"51"}
```

В примере показана смысловая часть записи. Поля `ts`, `service` и `version` добавляет настройка
журналирования одинаково ко всем записям — в коде их не пишут.

Обязательные поля в каждой записи:

| Поле | Что содержит | Зачем |
|---|---|---|
| `ts` | время в UTC, ISO 8601 | сопоставление записей разных сервисов |
| `level` | `error`, `warn`, `info`, `debug` | отбор при поиске и правила отбрасывания |
| `msg` | короткое неизменяемое описание события | одинаковый текст у всех случаев одного события — по нему они считаются |
| `service` | имя сервиса | в хранилище записи всех сервисов лежат вместе |
| `version` | версия сборки | отличает «сломалось после выката» от «было всегда» |
| `request_id` | идентификатор обращения | собирает историю одного запроса через все сервисы |

Переменная часть выносится в отдельные поля (`user_id`, `provider_code`, `duration_ms`), а не
вписывается в `msg`. Запись `"msg":"payment declined for user 42"` не даёт посчитать, сколько
отказов было всего: у каждой записи текст свой.

**Уровни по назначению:**

| Уровень | Когда |
|---|---|
| `error` | операция не выполнена, нужно вмешательство |
| `warn` | сработал запасной путь, работа продолжается |
| `info` | значимое событие бизнес-уровня |
| `debug` | подробности; в обычном режиме выключены |

**Сквозной идентификатор запроса.** Идентификатор присваивается на входе (шлюз) и передаётся
дальше по цепочке в заголовке. По нему собирается вся история одного обращения через все сервисы —
без него разбор в распределённой системе превращается в сопоставление по времени.

**Чего в журналах не должно быть:** паролей, токенов, ключей, номеров карт, содержимого документов.
Журналы читает больше людей, чем базу, и хранятся они в других местах. Идентификатор — можно,
значение — нет.

### Где это объявляется

По [BMBP](../architecture/BMBP.md) настройка журналирования — в `shared/logging`: от неё не зависит
ни один слой, и формат задаётся один раз на сервис. Присвоение `request_id` и запись строки о
завершении запроса — в посреднике (middleware) слоя `api`, рядом с подсчётом метрик.

**Вывод — в стандартный поток**, а не в файл внутри контейнера. Файл внутри контейнера означает, что
сервис сам занимается ротацией, том и права становятся его заботой, а при пересоздании контейнера
записи исчезают. В стандартный поток пишет и сборщик, и `docker compose logs` — второй способ
остаётся рабочим при любых проблемах с хранилищем.

**Одна запись — одна строка.** Сборщик разбивает поток по переводу строки, поэтому многострочный
след стека превращается в десяток бессвязных записей. Стек кладётся полем внутрь той же записи
(`"stack":"..."`).

---

## Набор инструментов

Роли те же, что у метрик: сбор и хранение. Набор — один из рабочих, не единственный; критерий
выбора прежний: ставится контейнером рядом с приложением и не требует внешней службы.

| Роль | Инструмент | Образ | Почему он |
|---|---|---|---|
| сбор журналов | Grafana Alloy | `grafana/alloy:v1.9.0` | читает вывод контейнеров через сокет Docker: приложение менять не нужно, новый контейнер подхватывается сам |
| хранение и поиск | Loki | `grafana/loki:3.5.0` | индексирует метки, а не текст: место занимает как сжатый архив, а не как поисковый движок |
| показ | Grafana | уже стоит | те же экраны, что у метрик; переход от графика к записям без смены инструмента |

**Проверка тегов:**

```bash
for i in grafana/alloy:v1.9.0 grafana/loki:3.5.0; do
  docker manifest inspect "$i" >/dev/null 2>&1 && echo "есть  $i" || echo "НЕТ   $i"
done
```

**Ожидается:** две строки `есть`. Если тега нет — возьмите ближайший выпущенный и запишите его в
`compose.yml`.

---

## Шаг 1. Хранилище

```bash
cd /opt/app/observability && mkdir -p loki alloy
```

```yaml
# loki/loki.yml
auth_enabled: false               # разграничение по клиентам не используется: установка одна

server:
  http_listen_port: 3100
  log_level: warn                 # иначе хранилище журналов становится источником журналов

common:
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

limits_config:
  retention_period: 336h          # 14 суток
  reject_old_samples: true
  reject_old_samples_max_age: 168h
  ingestion_rate_mb: 8            # предел на приём: один сервис в цикле не займёт весь диск
  ingestion_burst_size_mb: 16

compactor:
  working_directory: /loki/compactor
  retention_enabled: true         # без этой строки срок хранения не применяется
  delete_request_store: filesystem
```

Добавьте в `compose.yml` рядом с сервисами набора метрик:

```yaml
# compose.yml — записи добавляются в существующие разделы, а не новым файлом
volumes:
  loki_data:
  alloy_data:

services:
  loki:
    image: grafana/loki:3.5.0
    restart: unless-stopped
    command: ["-config.file=/etc/loki/loki.yml"]
    volumes:
      - ./loki/loki.yml:/etc/loki/loki.yml:ro
      - loki_data:/loki
    ports: ["127.0.0.1:3100:3100"]     # только с этой машины
    networks: [obs_net]
```

```bash
docker compose up -d loki
```

**Проверка:**

```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3100/ready
```

**Ожидается:** `200`. Первые полминуты после запуска возвращается `503` с текстом о том, что
приёмник не готов, — это штатное состояние запуска, а не отказ.

---

## Шаг 2. Сбор из контейнеров

```alloy
// alloy/config.alloy

// какие контейнеры существуют
discovery.docker "containers" {
  host             = "unix:///var/run/docker.sock"
  refresh_interval = "10s"
}

// какие метки поставить каждому потоку
discovery.relabel "containers" {
  targets = discovery.docker.containers.targets

  rule {                                   // имя контейнера приходит с ведущим слэшем
    source_labels = ["__meta_docker_container_name"]
    regex         = "/(.*)"
    target_label  = "container"
  }
  rule {
    source_labels = ["__meta_docker_container_label_com_docker_compose_service"]
    target_label  = "service"
  }
  rule {
    source_labels = ["__meta_docker_container_label_com_docker_compose_project"]
    target_label  = "project"
  }
}

// чтение вывода контейнеров
loki.source.docker "containers" {
  host       = "unix:///var/run/docker.sock"
  targets    = discovery.relabel.containers.output
  labels     = { job = "docker" }
  forward_to = [loki.process.app.receiver]
}

loki.process "app" {
  stage.json {                             // разбор JSON-записи
    expressions = { level = "level" }
  }

  stage.labels {                           // меткой становится только уровень
    values = { level = "" }
  }

  stage.drop {                             // отладочные записи не хранятся
    source = "level"
    value  = "debug"
  }

  stage.replace {                          // страховка от секретов, а не замена дисциплине
    expression = "(?i)(?:authorization|bearer|password|secret)[=: ]+\\S+"
    replace    = "скрыто"
  }

  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}
```

```yaml
# compose.yml, раздел services
  alloy:
    image: grafana/alloy:v1.9.0
    restart: unless-stopped
    command:
      - run
      - --storage.path=/var/lib/alloy/data      # отметки прочитанного переживают перезапуск
      - /etc/alloy/config.alloy
    volumes:
      - ./alloy/config.alloy:/etc/alloy/config.alloy:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - alloy_data:/var/lib/alloy/data
    networks: [obs_net]
    depends_on: [loki]
```

**Метки — то же, что в метриках: их мало.** Каждое сочетание значений меток — отдельный поток в
хранилище, и большое число потоков замедляет и приём, и поиск. Меткой становится то, по чему
отбирают целиком (`service`, `level`, `container`), а `request_id` и идентификаторы остаются полями
внутри записи: по ним ищут фильтром, и это дешевле.

**Сокет Docker.** Он смонтирован на чтение, но это не делает доступ безопасным: через сокет
запускается контейнер с примонтированным корнем хоста, то есть доступ равносилен правам `root` (см.
[Docker](./docker-install.md)). Отсюда правило: в этот контейнер ставится образ с закреплённой
версией, и он не собирается из чужого файла образа.

Альтернатива без сокета — драйвер журналирования Docker, отправляющий записи в хранилище напрямую.
Доступ к сокету он снимает, но при недоступном хранилище записи теряются либо запись в контейнере
блокируется. При сборе через сокет они дожидаются в файле на хосте — это и есть причина выбора.

```bash
docker compose up -d alloy
```

**Проверка** — метки появились, значит записи дошли:

```bash
curl -s http://127.0.0.1:3100/loki/api/v1/labels
curl -s 'http://127.0.0.1:3100/loki/api/v1/label/service/values'
```

**Ожидается:** `"status":"success"` и перечень меток, среди которых `service`, `level`, `container`;
во втором ответе — имена сервисов. Пустой перечень означает, что записи не поступают: смотрите
`docker compose logs alloy`.

---

## Шаг 3. Поиск

Источник данных подключается тем же файлом, что и Prometheus:

```yaml
# grafana/provisioning/datasources/datasources.yml — добавить в конец списка
  - name: Loki
    uid: loki
    type: loki
    access: proxy
    url: http://loki:3100
```

```bash
docker compose restart grafana
```

Запросы, которые закрывают разбор:

| Задача | Запрос |
|---|---|
| ошибки одного сервиса | `{service="backend"} \| json \| level="error"` |
| история одного обращения по всем сервисам | `{project="app"} \|= "7f3c"` |
| сколько записей какого уровня | `sum by (level) (count_over_time({service="backend"}[5m]))` |
| ошибки без известного шума | `{service="backend"} \| json \| level="error" != "connection reset"` |

**Проверка — запрос в хранилище:**

```bash
curl -sG http://127.0.0.1:3100/loki/api/v1/query_range \
  --data-urlencode 'query={service="backend"} | json | level="error"' \
  --data-urlencode 'limit=5' \
  --data-urlencode "start=$(date -u -d '1 hour ago' +%s)000000000" \
  --data-urlencode "end=$(date -u +%s)000000000" | head -c 400; echo
```

**Ожидается:** `"status":"success"` и непустой `"result"`, если ошибки за час были. Ответ
`"result":[]` при заведомо имевшихся ошибках означает, что метка `service` проставлена другим
значением — сверьте со списком из шага 2.

---

## Шаг 4. Ротация и срок хранения

Ограничения стоят в трёх местах, и каждое решает свою задачу:

| Где | Что ограничивает | Значение |
|---|---|---|
| Docker, `daemon.json` | размер файла на хосте, из которого читает сборщик | `max-size: 10m`, `max-file: 3` |
| Loki, `retention_period` | срок хранения в общем хранилище | 336 ч (14 суток) |
| правило `stage.drop` в сборщике | что не попадает в хранилище вовсе | уровень `debug` |

Файл на хосте — это буфер: пока сборщик недоступен, записи ждут в нём. Его размер — произведение
`max-size` на `max-file`, в настройке из документа о Docker это 30 МБ на контейнер. На какое время
хватает буфера, считается делением этого числа на суточный объём записей (измеряется ниже). Когда
буфер заполнен, самые старые записи перезаписываются: диск сервера приложения не резервируется под
журналы, и это осознанный размен.

Срок хранения назначается от того, как поздно обнаруживается ошибка. Две недели покрывают случай
«заметили в понедельник то, что началось на прошлой неделе». Больший срок стоит места: журналы
растут быстрее всего остального в системе.

**Объём измеряется, а не оценивается.** Через неделю работы:

```bash
docker system df -v | grep -E 'VOLUME|loki_data'
```

Полученный размер, поделённый на число суток и умноженный на срок хранения, даёт нужное место.
Если оно больше доступного — уменьшают срок или отбрасывают сборщиком то, что не читают: записи
уровня `info` от служебных опросов, обращения проверок готовности.

---

## Шаг 5. Секретов в журналах нет

Проверка та же, что в [документе о секретах](./secrets.md), но по хранилищу: значение читается из
файла, а не набирается, иначе оно останется в истории оболочки.

```bash
val=$(grep -m1 '^JWT_SIGNING_KEY=' /opt/app/backend/.secrets/.env | cut -d= -f2-)

curl -sG http://127.0.0.1:3100/loki/api/v1/query_range \
  --data-urlencode "query={job=\"docker\"} |= \"$val\"" \
  --data-urlencode "start=$(date -u -d '24 hours ago' +%s)000000000" \
  --data-urlencode "end=$(date -u +%s)000000000" | grep -cF "$val"
```

**Ожидается: `0`.** Ненулевой результат означает утечку: значение считается раскрытым, его отзывают
и заменяют (порядок — в документе о секретах), и только потом убирают причину записи.

Причины, по которым секрет оказывается в журнале, всегда одни и те же:

- логируется тело запроса или ответа целиком;
- логируются настройки при старте сервиса;
- в текст непредвиденной ошибки попадает строка подключения (её печатает драйвер базы).

Правило `stage.replace` из шага 2 закрывает часть случаев, но полагаться на него нельзя: оно ищет
известные формы записи, а строка подключения или тело запроса под них не подходят. Отсутствие
секрета обеспечивается на стороне приложения — списком полей, которые в журнал не выводятся.

**Проверка, что хранилище не слушается снаружи:**

```bash
sudo ss -ltnp | grep :3100        # ожидается: 127.0.0.1:3100
curl -m 5 -I http://example.com:3100    # с другой машины: ожидается таймаут или отказ
```

Открытое наружу хранилище журналов — это доступ к переписке системы: в записях видны маршруты,
идентификаторы, тексты ошибок, имена сервисов. Закрывается оно тем же способом, что остальные
внутренние службы: наружу не публикуется вовсе, а для просмотра есть Grafana через SSH-туннель (см.
[документ о метриках](./observability.md), шаг 4).

---

## От графика к записи

Порядок разбора описан в [документе о метриках](./observability.md). Журналы закрывают его третий
шаг, и на практике это три действия:

1. по графику доли ошибок определяется время начала;
2. запрос `sum by (service, level) (count_over_time({project="app"}[5m]))` за тот же период
   показывает, у какого сервиса всплеск;
3. первая запись уровня `error` из этого сервиса даёт `request_id`, по которому собирается вся
   история обращения: `{project="app"} |= "<request_id>"`.

Если на третьем шаге `request_id` в записи нет, разбор останавливается на сопоставлении по
времени — это и есть цена его отсутствия.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| в хранилище нет записей, контейнеры пишут | сборщик не видит сокет Docker | проверить том `/var/run/docker.sock` и `docker compose logs alloy` |
| записи есть, метки `service` нет | контейнер запущен не через Compose, метки проекта отсутствуют | добавить метку контейнера или брать имя из `container` |
| часть записей выглядит как обрывки | многострочный вывод (след стека) | писать одну запись одной строкой, стек — полем |
| поиск по идентификатору ничего не находит | нет сквозного идентификатора | присваивать на входе и передавать дальше по цепочке |
| приём отклоняется: `entry too far behind` | записи старше `reject_old_samples_max_age` | сборщик долго стоял; при разовом случае — пропустить, при постоянном — поднять предел |
| хранилище растёт, срок хранения не действует | не включён `retention_enabled` у compactor | включить и перезапустить, старые данные удалятся при следующем проходе |
| журналы занимают весь диск сервера приложения | не ограничен размер файла Docker | `max-size` и `max-file` в `daemon.json`, пересоздать контейнеры |
| в журналах обнаружились токены | логируется тело запроса целиком | исключить поля, отозвать засветившиеся значения |
| потоков десятки тысяч, поиск медленный | в метку попал идентификатор | вернуть идентификатор в поле записи, метки — только с конечным множеством значений |
| `docker compose logs` пуст, а в хранилище записи есть | приложение пишет в файл внутри контейнера | перевести вывод в стандартный поток |

---

## Откат и снятие

```bash
cd /opt/app/observability
docker compose stop alloy             # сбор прекращается, хранилище остаётся доступным
docker compose rm -sf alloy loki      # убрать оба сервиса; тома остаются
docker volume rm observability_loki_data     # удалить собранные записи — отдельным действием
```

Что при этом **не затрагивается**: журналы самих контейнеров. Они лежат на хосте в файлах Docker и
читаются как обычно:

```bash
cd /opt/app/backend && docker compose logs --tail=200 backend
```

Приложение, его тома и настройка журналирования тоже не задеты: сбор — отдельные контейнеры в
отдельном проекте Compose. Формат записи и `request_id` остаются в приложении и после снятия сбора —
они полезны и при чтении вывода контейнера напрямую.

---

Документ: http://docs.gitaspen.ru/development/operations/running-an-application

# Запуск приложения на сервере: файл compose, сети, проверки

Как довести сервер от состояния «Docker установлен, код и значения на месте» до состояния
«приложение работает, шлюз отвечает на `127.0.0.1:8000`, наружу не смотрит ни одна часть». Это то
исходное состояние, которое требуют [HTTPS для домена](./tls-certificates.md) и
[релиз и выкат](./release-and-deploy.md): оба начинают с работающего шлюза на loopback.

Раскладку слоёв — какой вход к чему ведёт и что публикуется — задаёт
[сетевой контур](./network-topology.md). Здесь она записывается в файл compose и проверяется
командами.

**Что нужно до начала:**

| Условие | Проверка | Ожидается |
|---|---|---|
| Docker и Compose работают | `docker compose version` | `Docker Compose version v2.…` |
| ограничен размер журналов | `docker info --format '{{.LoggingDriver}}'` | `json-file` |
| на сервере лежит каталог продукта с описаниями образов | `ls /opt/app` | каталоги частей и файл `compose.yml` |
| значения заполнены и закрыты | `stat -c '%a %U %n' /opt/app/app/.secrets/.env` | `600 <учётная запись выката>` |
| порт публикации свободен | `sudo ss -ltn \| grep :8000` | пустой вывод |
| команды выполняются без `sudo` | `docker ps` | таблица контейнеров без ошибки прав |

Непустой вывод пятой проверки означает, что порт занят другим процессом или прежним запуском: до
его освобождения `docker compose up` завершится ошибкой привязки.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| [Docker на сервере](./docker-install.md) — движок и правило публикации портов; [секреты](./secrets.md) — заполненный `.secrets/.env` рядом с каждой частью | состав частей, сети, порядок старта, ограничения, запуск и обновление | [HTTPS для домена](./tls-certificates.md) — вход и TLS; затем [релиз и выкат](./release-and-deploy.md) |

Что предыдущее звено обязано обеспечить: работающий `docker compose`, ротацию журналов и правило
публикации — порт публикуется **с явным адресом** `127.0.0.1`. Правило принципиально: Docker
добавляет свои правила в `iptables` раньше правил межсетевого экрана, поэтому публикация без адреса
открывает порт в интернет, даже если `ufw` его запрещает (разбор — в
[документе о Docker](./docker-install.md), шаг 4).

Что этот документ оставляет следующему: шлюз приложения отвечает на `127.0.0.1:8000`, ни одна
другая часть порт на хосте не занимает, у каждой части есть служебная ручка готовности. На этом
стоит следующий шаг: nginx хоста проксирует ровно на этот адрес, и его проверка
(`curl -I http://127.0.0.1:8000`) без работающего шлюза не проходит.

---

## Состав: что поднимается и что смотрит наружу

Приложение на сервере — это несколько частей в одном файле compose. Наружу из них не смотрит ни
одна: единственная опубликованная часть — шлюз, и опубликован он на loopback.

```
nginx хоста                          порт 443, TLS — вне файла compose
  │  proxy_pass → 127.0.0.1:8000
  ▼
gateway     ports: "127.0.0.1:8000:80"      единственная публикация
  │  сеть app_net
  ▼
app         expose: "8000"                  публикации нет
  │  сеть data
  ▼
db          публикации нет, данные в томе
```

| Часть | Роль | Публикация | Описание роли |
|---|---|---|---|
| `gateway` | маршруты, CORS, лимиты, своя ручка `/health` | `127.0.0.1:8000:80` | [BMGP](../architecture/BMGP.md) |
| `app` | предметная логика | нет, только `expose` | [BMBP](../architecture/BMBP.md) |
| `db` | хранилище | нет | [база данных](./database.md) |
| брокер событий (если есть) | доставка сообщений между частями | нет | [обмен сообщениями](./message-queues.md) |
| nginx перед частью (если часть на своём сервере) | вход со стороны частной сети | нет | [сетевой контур](./network-topology.md) |

Отдельный nginx перед `app` в этой раскладке не нужен: у части один вход — шлюз, и он же и есть её
nginx. Правило «сколько входов, столько и nginx» из [сетевого контура](./network-topology.md)
добавляет второй nginx тогда, когда появляется второй вход — обращение из частной сети с другого
сервера (раздел «Когда часть живёт на своём сервере»).

### Один файл compose или несколько

| Размещение частей | Как описывается | Чем обеспечен порядок старта |
|---|---|---|
| все части на одной машине | один файл `compose.yml` на продукт | `depends_on` с условием внутри файла |
| части на разных машинах | свой файл compose рядом с каждой частью | ничем: `depends_on` действует только внутри одного файла |

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

---

## Шаг 1. Файл compose: части, сети, публикация

Файл кладётся в корень каталога продукта на сервере: `/opt/app/compose.yml`. Рядом — каталоги
частей с описаниями образов и `.secrets/` у каждой:

```
/opt/app/
├── compose.yml
├── .env                     # параметры запуска: метки образов, номер порта публикации
├── gateway/
│   ├── container            # описание образа
│   ├── conf.d/              # маршруты шлюза
│   └── .secrets/
└── app/
    ├── container
    └── .secrets/.env
```

```yaml
# /opt/app/compose.yml
name: app-stack

networks:
  edge:                       # шлюз ↔ хост: единственная сеть с выходом наружу
  app_net:
    internal: true            # шлюз ↔ приложение
  data:
    internal: true            # приложение ↔ база

volumes:
  db_data:

services:
  gateway:
    build: { context: ./gateway, dockerfile: container }
    image: gateway:${GATEWAY_VERSION:-local}
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:80"   # доступен только процессам этой машины
    environment:
      APP_UPSTREAM: http://app:8000
    depends_on: [app]
    networks: [edge, app_net]

  app:
    build: { context: ./app, dockerfile: container }
    image: app:${APP_VERSION:-local}
    restart: unless-stopped
    expose: ["8000"]          # порт объявлен; на хосте не публикуется
    env_file:
      - ./app/.secrets/.env
    volumes:
      - ./app/.secrets:/app/.secrets:ro
    depends_on: [db]
    networks: [app_net, data]

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    env_file:
      - ./db/.secrets/.env    # имя базы, пользователь, пароль
    volumes:
      - db_data:/var/lib/postgresql/data
    networks: [data]
```

`restart: unless-stopped` поднимает части после перезагрузки сервера; без него запущенным остаётся
только сам Docker (см. [документ о Docker](./docker-install.md), шаг 5).

### `ports` и `expose`: почему часть не публикует порт вовсе

Разница директив разобрана в [документе о Docker](./docker-install.md) (шаг 4). Здесь важны два
следствия.

**Публикация обходит межсетевой экран.** Опубликованный без адреса порт доступен из интернета
независимо от правил `ufw`. Поэтому публикация — не «удобство для отладки», а создание входа, у
которого нет ни TLS, ни маршрутов, ни проверок шлюза.

**`expose` ничего не открывает и ничего не ограничивает.** Соседи по общей сети видят любой порт
контейнера и без этой строки; она нужна как объявление, чем именно часть отвечает. Недоступность
части снаружи обеспечивают две другие вещи: отсутствие `ports` и раскладка сетей.

### Внутренние сети и `internal: true`

Сеть с `internal: true` не имеет выхода наружу: контейнеры в ней не обращаются в интернет, и
опубликовать в такой сети порт нельзя. Отсюда раскладка: часть с публикацией (`gateway`) состоит в
обычной сети `edge`, остальные связи — во внутренних.

Сети данных и входа разделены намеренно. Части соединены только там, где им нужно разговаривать:

- `gateway` не состоит в сети `data` и не имеет маршрута к базе. Часть, до которой можно
  дотянуться с хоста, не может обратиться к хранилищу даже при ошибке в её конфигурации;
- `db` состоит только в `data`: обратиться к ней может лишь `app`;
- одна общая сеть на все части даёт обратное — доступность порта базы любому контейнеру продукта.

Ограничение действует в обе стороны: часть, которой нужно обращаться к внешней системе (платёжный
провайдер, почтовый сервер), из сетей `internal: true` её не увидит — такой части нужна не-internal
сеть.

### Переменные и значения

Секретные значения в файл compose не пишутся: они лежат в `.secrets/.env` рядом с частью и
подключаются через `env_file` и том только на чтение. Хранение, права, доставка на сервер и замена
— в [документе о секретах](./secrets.md).

Файлов с расширением `.env` в схеме два, и это разные механизмы:

| Файл | Кто читает | Что там держат |
|---|---|---|
| `<часть>/.secrets/.env` | контейнер, через `env_file` | значения для приложения, в том числе секретные |
| `/opt/app/.env` рядом с `compose.yml` | сам Compose, для подстановки `${…}` | параметры запуска: метки образов, номер порта публикации |

Второй файл секретов не содержит: подставленные значения печатает `docker compose config`.

**Проверка:**

```bash
cd /opt/app
docker compose config >/dev/null && echo OK      # синтаксис и подстановки
docker compose up -d
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
```

**Ожидается** — порты указаны только у `gateway` и только с адресом `127.0.0.1`:

```
SERVICE   STATUS          PORTS
app       Up 2 minutes
db        Up 2 minutes
gateway   Up 2 minutes    127.0.0.1:8000->80/tcp
```

```bash
sudo ss -ltnp | grep -E ':(8000|5432)'
```

**Ожидается:** одна строка с `127.0.0.1:8000`. Строка с `0.0.0.0:8000` или порт базы в выводе
означают публикацию, которой быть не должно.

---

## Шаг 2. Проверки готовности и порядок старта

Порядок принципиален: проверки заводятся до ограничений, иначе неясно, чем вызван отказ — кодом или
пределом.

`depends_on` в коротком виде (шаг 1) ждёт только **создания** контейнера соседа. Контейнер базы
считается запущенным на первой секунде, а соединения она принимает позже — за это время `app`
успевает попытаться подключиться и завершиться. С `restart: unless-stopped` он перезапускается по
кругу, и это выглядит как «сервис не поднялся», хотя причина во времени старта.

Условие снимает неопределённость: Compose держит зависимую часть, пока условие не выполнено.

| Условие | Когда снимается | Требует от зависимости |
|---|---|---|
| `service_started` | контейнер создан и запущен (значение по умолчанию) | ничего |
| `service_healthy` | проверка готовности прошла успешно | блок `healthcheck` |
| `service_completed_successfully` | контейнер завершился с кодом `0` | завершающаяся задача (например, миграции) |

Блоки добавляются к описаниям частей из шага 1:

```yaml
  gateway:
    depends_on:
      app:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 5s

  app:
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health/ready"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 20s       # запас на миграции при первом старте

  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
      interval: 10s
      timeout: 3s
      retries: 10
      start_period: 10s
```

Что означают параметры:

- `test` — команда, выполняемая **внутри** контейнера. Инструмент должен быть в образе: в
  alpine-образах есть `wget`, `curl` есть не везде; для образа без обоих проверка пишется на
  интерпретаторе языка приложения. Двойной `$$` в `CMD-SHELL` оставляет `$` для оболочки
  контейнера: одиночный `$` Compose счёл бы своей подстановкой;
- `interval`, `timeout`, `retries` — как часто, сколько ждать ответа, сколько неудач подряд до
  состояния `unhealthy`;
- `start_period` — окно после старта, в котором неудачные попытки не считаются. Без него часть с
  долгим первым запуском (миграции, прогрев кеша) объявляется неисправной раньше, чем успевает
  подняться.

Адрес в проверке — `127.0.0.1` внутри контейнера, и это корректно: команда выполняется в том же
сетевом пространстве. Но **слушать** процесс обязан `0.0.0.0` внутри контейнера, иначе соседи по
сети до него не достучатся, а проверка при этом будет проходить. Правило «привязываться к loopback»
относится к процессам на хосте, а не внутри контейнера: там границу задают сети и отсутствие
публикации.

Ручек две, и они отвечают разное: «жив» — процесс отвечает; «готов» — зависимости доступны, можно
давать трафик (разделение описано в [релизе и выкате](./release-and-deploy.md)). Ручка шлюза
отвечает **сама за себя**, не опрашивая бэки ([BMGP](../architecture/BMGP.md)): иначе отказ одной
части делает неисправным весь контур.

Неуспешная проверка сама по себе контейнер не перезапускает: Compose использует её при старте
(`depends_on`) и показывает состояние в `ps`.

**Проверка** — поднять с нуля и убедиться, что порядок соблюдён:

```bash
docker compose down
docker compose up -d --wait --wait-timeout 120 && echo READY
docker compose ps --format 'table {{.Service}}\t{{.Status}}'
```

**Ожидается:** `READY` и состояние `Up … (healthy)` у всех трёх частей. Ключ `--wait` возвращает
ненулевой код, если какая-то часть не стала готовой за отведённое время, — по нему выкат отличает
«поднялось» от «запустилось».

```
SERVICE   STATUS
app       Up 40 seconds (healthy)
db        Up 55 seconds (healthy)
gateway   Up 30 seconds (healthy)
```

Порядок запуска виден по времени в колонке `STATUS`: `db` старше `app`, `app` старше `gateway`.

---

## Шаг 3. Ограничения и права контейнера

Ограничения добавляются к заведомо работающей части. Они не улучшают её работу — они локализуют
отказ: ошибка в одной части не должна забирать память, процессор и число процессов у соседей и у
самой машины.

```yaml
  app:
    read_only: true                  # корневая ФС контейнера только на чтение
    tmpfs:
      - /tmp:rw,nosuid,nodev,noexec,size=16m
    security_opt:
      - no-new-privileges:true
    cap_drop: [ALL]
    user: "10001:10001"              # процесс не от root
    pids_limit: 128
    mem_limit: 512m
    cpus: 1.0
```

| Настройка | Что даёт |
|---|---|
| `mem_limit` | при превышении ядро убивает процесс в контейнере, а не начинает вытеснять память всей машины; отказ остаётся в одной части |
| `cpus` | доля процессорного времени: часть не занимает все ядра и не тормозит соседей |
| `pids_limit` | предел числа процессов и потоков; ограничивает разрастание при ошибке — цикл порождения процессов упирается в предел, а не в машину |
| `read_only` | корневая ФС только на чтение: записанный файл не переживёт перезапуск, а попытка записи видна сразу как отказ |
| `tmpfs` | каталоги, куда процесс обязан писать (кеш, `/tmp`, `/var/run`), — в памяти, с ограничением объёма; `noexec` запрещает исполнение из них, `nosuid` — повышение прав через setuid-файл |
| `cap_drop: [ALL]` | снимает привилегии ядра: смена владельца файлов, изменение сетевых настроек, монтирование становятся недоступны |
| `no-new-privileges` | процесс не может повысить права через setuid-программу, оставшуюся в образе |
| `user` | процесс работает не от root: файл вне тома он не перезапишет |

Значения подбираются по фактическому потреблению, а не «на глаз»: `docker stats --no-stream`
показывает память и процессор под нагрузкой. Предел ставится с запасом над наблюдаемым пиком —
слишком тесный `mem_limit` даёт отказ, неотличимый по симптомам от утечки памяти.

Два ограничения этой раскладки:

- порт ниже 1024 после `cap_drop: [ALL]` занять нельзя. Шлюз слушает 80 внутри контейнера, поэтому
  ему добавляется `cap_add: [NET_BIND_SERVICE]`. Второй вариант — слушать порт выше 1024 внутри
  контейнера: публикация всё равно назначает свой номер;
- `read_only` подходит не всем образам. Образ базы пишет за пределами тома данных, поэтому у `db`
  корневая ФС остаётся записываемой, а ограничения сводятся к пределам ресурсов.

**Проверка** — часть работает с ограничениями, и они действительно применены:

```bash
docker compose up -d
docker compose ps --format 'table {{.Service}}\t{{.Status}}'    # ожидается: healthy у всех

docker inspect "$(docker compose ps -q app)" \
  --format '{{.HostConfig.Memory}} {{.HostConfig.PidsLimit}} {{.HostConfig.ReadonlyRootfs}}'
# ожидается: 536870912 128 true

docker compose exec app sh -c 'touch /var/lib/probe 2>&1 || true'
# ожидается: Read-only file system
```

Последняя команда подтверждает, что `read_only` не обошли монтированием: если файл создался,
каталог записываем и ограничение на него не действует.

---

## Шаг 4. Сквозная проверка контура

Проверка идёт снизу вверх: первый слой, который не отвечает, и есть место отказа (разбор по слоям —
в [сетевом контуре](./network-topology.md)).

```bash
# 1. приложение отвечает соседям по внутренней сети
docker compose exec gateway wget -qO- http://app:8000/health/ready

# 2. шлюз отвечает на своей ручке
docker compose exec gateway wget -qO- http://127.0.0.1/health

# 3. шлюз доступен с хоста
curl -sf http://127.0.0.1:8000/health && echo OK
```

**Ожидается:** ответы служебных ручек на всех трёх и `OK` на третьей.

Если в образе шлюза нет ни `wget`, ни `curl`, первый запрос выполняется разовым контейнером в той
же сети:

```bash
docker network ls --filter name=app_net --format '{{.Name}}'    # ожидается: app-stack_app_net
docker run --rm --network app-stack_app_net curlimages/curl -sf http://app:8000/health/ready
```

Имя сети складывается из имени проекта (ключ `name:` в файле compose) и имени сети.

**Проверка снаружи — с другой машины:**

```bash
curl -m 5 -I http://example.com:8000     # ожидается таймаут или отказ соединения
```

Ответ на этот запрос означает, что шлюз опубликован на всех интерфейсах и доступен в обход TLS.
Исправьте публикацию (шаг 1) и повторите: настраивать домен до этого бессмысленно.

На этом состояние, требуемое [документом о HTTPS](./tls-certificates.md), достигнуто.

---

## Повседневные операции

```bash
cd /opt/app

docker compose up -d                        # поднять; пересоздаются только изменившиеся части
docker compose ps -a                        # состояние, включая завершившиеся, с кодом выхода
docker compose logs -f --tail=100 app       # журнал одной части
docker compose logs --since 10m             # журнал всех частей за период

docker compose stop                         # остановить; контейнеры и тома остаются
docker compose start                        # поднять остановленные
docker compose restart app                  # перезапуск процесса без пересоздания контейнера
```

**Обновление одной части.** Части обновляются по отдельности; остальные не перезапускаются:

```bash
docker compose build app                    # если образ собирается здесь
docker compose pull app                     # если образ берётся из реестра
docker compose up -d --wait app
```

Метка образа задаётся переменной (`app:${APP_VERSION:-local}`), а значение хранится в `/opt/app/.env`
рядом с файлом compose. Значение, переданное в командной строке разово, при следующем
`docker compose up -d` не применяется — вернётся то, что записано в файле.

**Чего не делает `restart`.** Он перезапускает существующий контейнер: ни новый образ, ни
изменившийся `.secrets/.env` при этом не перечитываются. Пересоздание — это `up -d`, а при
неизменном описании — `up -d --force-recreate <часть>`.

**Где искать причину, если часть не поднялась:**

| Что смотреть | Команда | Что видно |
|---|---|---|
| код выхода | `docker compose ps -a` | `Exited (1)` — процесс завершился сам; `Exited (137)` — убит по пределу памяти |
| вывод процесса | `docker compose logs --no-log-prefix app` | сообщение об ошибке при старте |
| попытки проверки готовности | `docker inspect "$(docker compose ps -q app)" --format '{{json .State.Health}}'` | вывод и код возврата последних проверок |
| убит ли по памяти | `docker inspect "$(docker compose ps -q app)" --format '{{.State.OOMKilled}}'` | `true` — предел `mem_limit` мал или в части утечка |
| итоговое описание | `docker compose config` | как Compose прочитал файл после подстановок |

Пустой журнал при незапустившейся части означает, что процесс не начал работу: ошибка в команде
запуска, отсутствующий файл, неверные права. Такие сообщения ищутся в `docker compose ps -a` и в
`docker inspect`, а не в журнале приложения.

---

## Когда часть живёт на своём сервере

Раскладка не меняется, меняется способ описания: у части появляется свой файл compose и свой nginx
на вход со стороны частной сети. Шлюз обращается к ней по адресу из переменной окружения, а не по
имени контейнера. Готовый пример такого файла — в [сетевом контуре](./network-topology.md), раздел
о частной сети.

Что при этом меняется в эксплуатации:

- `depends_on` больше не обеспечивает порядок: части поднимаются независимо, и каждая обязана
  переживать недоступность соседа;
- проверка «изнутри сети» выполняется с сервера шлюза, а не через `docker compose exec`;
- сам сервис по-прежнему не публикует порт: вход к нему — только через свой nginx.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| часть в состоянии `unhealthy`, хотя процесс работает | команда проверки не годится: нет инструмента в образе, не тот порт или путь | `docker inspect … '{{json .State.Health}}'` — там вывод последних попыток |
| часть «поднялась», сосед получает `Connection refused` | процесс слушает `127.0.0.1` внутри контейнера | слушать `0.0.0.0` внутри контейнера; loopback — правило для процессов на хосте |
| `Name or service not known` при обращении по имени части | части в разных сетях compose | добавить общую сеть обеим |
| nginx не стартует: `host not found in upstream` | имя соседа резолвится при старте, а сосед ещё не создан | `depends_on` с условием готовности |
| `bind: address already in use` при `up` | порт хоста занят другим процессом или прежним запуском | `sudo ss -ltnp \| grep :8000`, освободить порт или сменить номер публикации |
| `Permission denied` при записи в каталог тома | каталог создан от root, процесс работает под `user:` | создать каталог заранее нужному владельцу (`sudo install -d -o 10001 -g 10001 <путь>`) либо перейти на именованный том |
| `Read-only file system` при записи внутри контейнера | `read_only: true` без `tmpfs` для этого каталога | добавить каталог в `tmpfs` или вынести в том |
| часть перезапускается по кругу | стартовала раньше зависимости и завершилась | `depends_on` с `condition: service_healthy` у зависимости |
| контейнер завершился с кодом `137` | превышен предел памяти | `docker inspect … '{{.State.OOMKilled}}'`; поднять `mem_limit` после замера `docker stats` |
| образ обновился, а работает прежняя версия | метка не изменилась, контейнер не пересоздан | `docker compose pull <часть> && docker compose up -d <часть>`; ставить метку версии, а не `latest` |
| после правки `.secrets/.env` поведение прежнее | окружение фиксируется при создании контейнера | `docker compose up -d --force-recreate <часть>` (см. [секреты](./secrets.md)) |
| часть не достучалась до внешней системы | она состоит только в сетях `internal: true` | добавить ей не-internal сеть |
| nginx не стартует: `bind() to 0.0.0.0:80 failed (13: Permission denied)` | сняты все привилегии, порт ниже 1024 | `cap_add: [NET_BIND_SERVICE]` либо слушать порт выше 1024 |
| сервис доступен снаружи по `http://example.com:8000` | порт опубликован без адреса | вернуть `127.0.0.1:` в публикацию ([Docker](./docker-install.md), шаг 4) |
| `docker compose ps` пуст, хотя контейнеры работают | команда выполняется не из каталога с файлом compose | выполнять из `/opt/app` либо указать `-p <имя проекта>` |

---

## Откат и снятие

**Новая версия части не подошла.** Откат — возврат прежней метки образа и пересоздание одной части:

```bash
cd /opt/app
sed -i 's/^APP_VERSION=.*/APP_VERSION=<прежняя метка>/' .env
docker compose up -d --wait app
curl -sf http://127.0.0.1:8000/health && echo OK
```

Условие выполнимости: прежний образ ещё лежит на машине. `docker image prune` до подтверждения
новой версии его удаляет, и откат превращается в сборку заново. Выкат без простоя, где прежняя
копия остаётся запущенной, описан в [релизе и выкате](./release-and-deploy.md).

**Остановка без потери данных:**

| Команда | Что удаляет | Что остаётся |
|---|---|---|
| `docker compose stop` | ничего | контейнеры, сети, тома |
| `docker compose down` | контейнеры и сети | именованные тома, образы, `.secrets/` |
| `docker compose down -v` | контейнеры, сети **и именованные тома** | образы, `.secrets/` |

Ключ `-v` удаляет данные базы. Для остановки он не нужен ни в каком случае; применяется только при
осознанном сбросе установки и после снятия копии
([резервное копирование](./backup-and-restore.md)).

**Проверка после остановки:**

```bash
docker compose ps -a         # ожидается: пусто после down
docker volume ls             # ожидается: том с данными на месте
sudo ss -ltn | grep :8000    # ожидается: пустой вывод
```

**Снятие части совсем:**

1. `docker compose down` — остановить и удалить контейнеры;
2. снять копию тома и убедиться, что она восстанавливается;
3. `docker volume rm <том>` — удалить данные;
4. убрать описание части из файла compose и её каталог.

Что не удаляется само: образы (`docker image prune`), значения в `.secrets/` и копии этого каталога
в резервных архивах. Если часть выводится из эксплуатации вместе с её доступами, значения ещё и
отзываются на проверяющей стороне — порядок в [документе о секретах](./secrets.md).

---

Документ: http://docs.gitaspen.ru/development/operations/observability

# Наблюдение: метрики, показ, оповещения

Документ доводит установку от «сервис выкачен и закрыт шлюзом» до «числа снимаются, графики
открываются, оповещение приходит на проверенный отказ». Каждый шаг заканчивается проверкой с
однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы»,
не выполняя следующий шаг.

Журналы вынесены в отдельный документ — [Журналы](./logs.md). Они ставятся в тот же каталог и тем же
файлом compose, но у них другой набор инструментов, другой срок хранения и другие проверки. Метрики
показывают, **что** не так и **когда** началось; журналы объясняют, **что именно** произошло. Один
без другого разбор не закрывает, поэтому документы читаются подряд.

**Что нужно до начала:** сервер с Docker, приложение и его шлюз запущены, домен с TLS, у сервисов
есть ручки «жив» и «готов», в ответе видна версия.

```bash
docker compose version                                              # версия Compose v2
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/health  # 200
```

**Ожидается:** версия из двух слов (`docker compose`, не `docker-compose`) и код `200`. Без рабочей
ручки готовности половина проверок ниже не имеет смысла: нечего опрашивать.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| приложение выкачено за шлюзом, резервные копии настроены | набор инструментов, ручка метрик, правила оповещений, проверки | [сбор журналов](./logs.md); разбор конкретного отказа по слоям контура |

Что предыдущее звено обязано обеспечить: у сервисов есть ручки «жив» и «готов», а в ответе видна
версия — без этого невозможно отличить «сервис не запущен» от «запущен, но не готов», и непонятно,
какая версия сейчас отвечает. Отсюда же требование к выкату: он оставляет отметку времени, иначе
всплеск ошибок не с чем сопоставить (см. [Релиз и выкат](./release-and-deploy.md) и раздел «Отметки
о выкатах»).

Что этот документ оставляет следующему: работающий Prometheus и Grafana в отдельном проекте
Compose. Документ о журналах добавляет в этот же проект два сервиса и второй источник данных в
Grafana, не переделывая ничего из настроенного здесь.

---

## Три вида данных

| Вид | Отвечает на вопрос | Хранится | Где описан |
|---|---|---|---|
| метрики | сколько и как быстро — числа во времени | долго, дёшево | этот документ |
| журналы (логи) | что именно произошло в конкретном случае | недолго, дороже | [Журналы](./logs.md) |
| трассировки | где именно ушло время в цепочке сервисов | выборочно | не описаны |

Метрики показывают, что что-то не так, и когда началось. Журналы объясняют, что произошло.
Трассировки показывают, какой участок цепочки виноват, когда сервисов несколько.

Начинать стоит с метрик и журналов: трассировки нужны, когда цепочка сервисов длинная и непонятно,
какое звено тормозит.

---

## Что измерять

Для любого сервиса, принимающего запросы, достаточно четырёх величин:

| Величина | Что показывает | На что смотреть |
|---|---|---|
| частота запросов | нагрузка | резкие провалы — признак отказа выше по цепочке |
| доля ошибок | качество ответов | рост доли `5xx`; `4xx` отдельно — это чаще про клиента |
| время ответа | скорость | распределение, не среднее (см. ниже) |
| насыщение | запас ресурсов | процессор, память, место на диске, занятость пула соединений |

**Среднее время ответа скрывает проблему.** Если 95 % запросов укладываются в 50 мс, а 5 % занимают
5 секунд, среднее покажет приемлемые 300 мс, хотя каждый двадцатый пользователь ждёт пять секунд.
Смотреть надо на процентили: срединное значение и «медленный хвост» (95-й, 99-й).

Дополнительно — величины, специфичные для системы: длина очереди необработанных сообщений, возраст
последней резервной копии, срок до истечения сертификата. Они предупреждают об отказе заранее.

---

## Набор инструментов

Роли и один рабочий набор под них. Это **не единственный возможный** набор: те же роли закрывают и
другие инструменты, в том числе управляемые службы. Критерий выбора здесь один — всё ставится
контейнерами рядом с приложением, не требует внешней службы и не тянет за собой отдельную
эксплуатацию.

| Роль | Инструмент | Образ | Почему он |
|---|---|---|---|
| сбор метрик | Prometheus | `prom/prometheus:v3.5.0` | опрашивает сервисы сам по HTTP: сервису не нужно знать про приёмник и уметь отправлять |
| хранение метрик | он же | — | своя база рядов внутри Prometheus; отдельное хранилище нужно, когда одного сервера мало |
| показ | Grafana | `grafana/grafana-oss:12.0.0` | графики и таблицы поверх Prometheus; тот же экран потом покажет журналы |
| оповещения | Alertmanager | `prom/alertmanager:v0.28.1` | группировка, подавление и повтор — то, что отличает оповещение от потока писем |
| показатели хоста | node_exporter | `prom/node-exporter:v1.9.1` | диск, память, процессор машины; приложение их не отдаёт |
| проверка снаружи | blackbox_exporter | `prom/blackbox-exporter:v0.25.0` | запрашивает домен как обычный клиент и заодно отдаёт срок сертификата |
| сбор журналов | Grafana Alloy | см. [Журналы](./logs.md) | — |
| хранение журналов | Loki | см. [Журналы](./logs.md) | — |

Версии образов зафиксированы: тег `latest` делает установку невоспроизводимой — на двух машинах
окажутся разные версии, а причина расхождения не видна ни в одном файле.

**Проверка, что теги существуют** (список выпусков меняется; если тега нет — возьмите ближайший
выпущенный и запишите его в файл compose):

```bash
for i in prom/prometheus:v3.5.0 grafana/grafana-oss:12.0.0 prom/alertmanager:v0.28.1 \
         prom/node-exporter:v1.9.1 prom/blackbox-exporter:v0.25.0; do
  docker manifest inspect "$i" >/dev/null 2>&1 && echo "есть  $i" || echo "НЕТ   $i"
done
```

**Ожидается:** пять строк `есть`.

---

## Шаг 1. Ручка метрик в сервисе

Сервис отдаёт текущие значения по HTTP, Prometheus их забирает. Ручка объявляется в группе
`internal` — `/internal/metrics`: по [BMBP](../architecture/BMBP.md) это группа отладочных и
административных маршрутов, которые не проходят через шлюзы. Путь по умолчанию у большинства
клиентских библиотек — `/metrics`; несовпадение снимается параметром `metrics_path` в настройке
Prometheus (шаг 3), менять раскладку маршрутов под инструмент не требуется.

### Что отдавать

| Показатель | Имя и тип | Метки | Зачем |
|---|---|---|---|
| частота запросов и доля ошибок | `http_requests_total`, счётчик | `service`, `method`, `route`, `status` | обе величины считаются из одного счётчика |
| время ответа | `http_request_duration_seconds`, гистограмма | `service`, `method`, `route` | процентили считаются из корзин |
| пул соединений с базой | `db_pool_connections`, `db_pool_size`, датчики | `service`, `state` (`in_use`, `idle`) | исчерпание пула превращается в очередь на входе |
| очередь сообщений | `queue_messages_pending`, датчик | `service`, `topic` | потребитель не справляется с отправителем |
| версия | `app_build_info` со значением `1`, датчик | `service`, `version` | сопоставление всплеска с выкатом |

Границы корзин гистограммы задаются от обещанного времени ответа, например
`0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10` секунд. Процентиль вычисляется по корзинам, поэтому его
точность ограничена сеткой: если все значения попадают в одну корзину, различить их нельзя.

### Где это объявляется

- счётчик и гистограмма заполняются посредником (middleware) в `api/_shared` — там же, где конверт
  ответа и обработка ошибок. В обработчиках доменов их нет: иначе каждый домен считает по-своему и
  часть маршрутов остаётся неучтённой;
- `db_pool_connections` и `db_pool_size` берутся из `infrastructure/db/connection` — состояние пула
  знает только он;
- `queue_messages_pending` — из клиента событий;
- `core` метрик не касается: инвариант «`core` не знает о транспорте» действует и здесь.

### Кардинальность: чего в метках не бывает

Метка — это то, у чего конечное и небольшое множество значений. Каждое сочетание значений меток —
отдельный ряд в базе, и ряд, переставший обновляться, занимает место до конца срока хранения.

- **`route` — шаблон маршрута**, а не фактический путь: `/api/front/orders/{id}`, не
  `/api/front/orders/8f21…`. Идентификатор в метке превращает один ряд в столько рядов, сколько
  было заказов;
- идентификаторы пользователя, запроса, документа в метки не попадают вовсе. Их место — в журнале
  (см. [Журналы](./logs.md)): там они стоят дёшево, а искать по ним удобнее.

### Проверка

Ручка не опубликована наружу, поэтому запрос идёт изнутри сети приложения. Узнайте имя сети и
обратитесь к сервису по имени:

```bash
docker network ls --format '{{.Name}}' | grep -vE '^(bridge|host|none)$'
APP_NET=<имя сети приложения>

docker run --rm --network "$APP_NET" curlimages/curl:8.7.1 \
  -sf http://backend:8000/internal/metrics | head -20
```

**Ожидается:** строки вида `http_requests_total{...} 123` и `# HELP`/`# TYPE` перед ними. Отказ
соединения означает, что имя сервиса или порт другие; пустой ответ — что ручка объявлена, но
посредник ничего не считает.

**Через шлюз ручка отдаваться не должна:**

```bash
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/internal/metrics
```

**Ожидается:** `404` (маршрут `/internal` на шлюз не заведён). Ответ `200` означает, что внутренние
ручки видны из интернета — уберите `/internal` из маршрутов шлюза, см.
[Сетевой контур](./network-topology.md).

---

## Шаг 2. Каталог, файлы, сеть

Наблюдение живёт **отдельным проектом Compose** в своём каталоге. Так его снятие не задевает
приложение, а тома с историей метрик не путаются с томами данных.

```
/opt/app/observability/
├── compose.yml
├── .env                                   # параметры запуска (не секреты)
├── .secrets/
│   ├── .env                               # пароль входа в Grafana
│   ├── .env.example
│   └── smtp_password                      # пароль почтового ящика оповещений
├── prometheus/
│   ├── prometheus.yml
│   └── alerts.yml
├── alertmanager/
│   └── alertmanager.yml
├── blackbox/
│   └── blackbox.yml
└── grafana/provisioning/datasources/
    └── datasources.yml
```

Раскладка `.secrets/` и права на неё — [Секреты](./secrets.md). Файл `.env` рядом с `compose.yml` —
это подстановка на стороне инструмента, она годится для параметров запуска и не годится для
значений: `docker compose config` печатает их в открытом виде.

Создайте каталоги и узнайте имя сети приложения — по нему Prometheus обращается к сервисам по
именам:

```bash
sudo install -d -o "$USER" -m 755 /opt/app/observability
cd /opt/app/observability
mkdir -p prometheus alertmanager blackbox grafana/provisioning/datasources
install -d -m 700 .secrets

docker network ls --format '{{.Name}}' | grep -vE '^(bridge|host|none)$'
echo "APP_NETWORK=<имя сети приложения>" > .env
```

Compose добавляет к имени сети имя проекта, поэтому в списке она выглядит как `<проект>_host_net`,
а не `host_net`.

### compose.yml

```yaml
name: observability

networks:
  obs_net:
    driver: bridge          # внутренняя сеть набора
  app_net:
    external: true          # сеть приложения: создана его проектом, здесь не создаётся
    name: ${APP_NETWORK}

volumes:                    # именованные тома: данные переживают пересоздание контейнеров
  prometheus_data:
  grafana_data:
  alertmanager_data:

services:
  prometheus:
    image: prom/prometheus:v3.5.0
    restart: unless-stopped
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.path=/prometheus"
      - "--storage.tsdb.retention.time=90d"    # срок хранения рядов
      - "--storage.tsdb.retention.size=8GB"    # и предел по месту: что наступит раньше
      - "--web.enable-lifecycle"               # перечитывание настроек без перезапуска
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
      - prometheus_data:/prometheus
    ports: ["127.0.0.1:9090:9090"]             # только с этой машины
    networks: [obs_net, app_net]

  alertmanager:
    image: prom/alertmanager:v0.28.1
    restart: unless-stopped
    command: ["--config.file=/etc/alertmanager/alertmanager.yml"]
    volumes:
      - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
      - ./.secrets:/etc/alertmanager/secrets:ro   # пароль почты — файлом, не окружением
      - alertmanager_data:/alertmanager
    ports: ["127.0.0.1:9093:9093"]
    networks: [obs_net]

  grafana:
    image: grafana/grafana-oss:12.0.0
    restart: unless-stopped
    env_file: ["./.secrets/.env"]                 # GF_SECURITY_ADMIN_PASSWORD
    environment:
      - GF_USERS_ALLOW_SIGN_UP=false
      - GF_AUTH_ANONYMOUS_ENABLED=false
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - grafana_data:/var/lib/grafana
    ports: ["127.0.0.1:3000:3000"]
    networks: [obs_net]
    depends_on: [prometheus]

  node_exporter:
    image: prom/node-exporter:v1.9.1
    restart: unless-stopped
    command:
      - "--path.rootfs=/host"
      - "--collector.textfile.directory=/textfile"
      - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|run)($$|/)"
    pid: host
    volumes:
      - /:/host:ro,rslave
      - /var/lib/node-exporter/textfile:/textfile:ro
    expose: ["9100"]                              # порт объявлен, но не опубликован
    networks: [obs_net]

  blackbox:
    image: prom/blackbox-exporter:v0.25.0
    restart: unless-stopped
    command: ["--config.file=/etc/blackbox/blackbox.yml"]
    volumes:
      - ./blackbox/blackbox.yml:/etc/blackbox/blackbox.yml:ro
    expose: ["9115"]
    networks: [obs_net]
```

**Почему наружу ничего не публикуется.** Правило контура одно: единственный вход из интернета —
порты 80 и 443 на сервере шлюза, всё остальное закрывается тем же способом, что прочие внутренние
службы (см. [Сетевой контур](./network-topology.md)). У наблюдения это правило имеет ещё одно
основание: метрики показывают внутреннее устройство системы — имена сервисов, маршруты, версии,
нагрузку, — а Grafana и Alertmanager принимают вход по паролю, то есть открытый наружу порт
становится ещё одной дверью с подбором пароля.

Три порта опубликованы **на loopback**: они нужны для проверок из этого документа и для доступа
через SSH-туннель. Опубликованный на loopback порт доступен только тому, кто уже вошёл на машину.
Экспортеры не публикуются вовсе: к ним обращается только Prometheus изнутри сети.

`$$` в строке `--collector.filesystem.mount-points-exclude` — это экранированный `$`: Compose
подставляет переменные в текст файла, и одиночный `$` он попытается развернуть.

Тома **именованные, а не привязанные каталоги хоста**: образы работают не от `root` (Prometheus —
от `nobody`, Grafana — от своего пользователя), и созданный вами каталог оказался бы им недоступен
на запись.

Каталог для показателей, которые пишут скрипты (используется в шаге 5):

```bash
sudo install -d -m 755 /var/lib/node-exporter/textfile
```

### Файл значений

```bash
umask 077
cat > .secrets/.env.example <<'EOF'
# Шаблон. Скопировать в .secrets/.env и заполнить.
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=CHANGE_ME
EOF
cp .secrets/.env.example .secrets/.env

openssl rand -hex 24                       # значение для GF_SECURITY_ADMIN_PASSWORD
printf '%s' '<пароль почтового ящика>' > .secrets/smtp_password    # без перевода строки

chmod 700 .secrets && chmod 600 .secrets/.env .secrets/smtp_password
chmod 644 .secrets/.env.example
```

Пароль входа в Grafana генерируется, а не придумывается, и заменяется до первого запуска: Grafana с
паролем по умолчанию и открытым туннелем — это доступ к внутренним показателям для любого, кто вошёл
на машину. Файл `smtp_password` записывается **без завершающего перевода строки**: он передаётся как
значение целиком, и лишний символ выглядит как неверный пароль.

---

## Шаг 3. Prometheus: кого опрашивать

```yaml
# prometheus/prometheus.yml
global:
  scrape_interval: 15s          # шаг опроса: чаще — точнее и дороже по месту
  evaluation_interval: 15s      # шаг проверки правил оповещений
  external_labels:
    installation: main          # видно в письме, когда установок несколько

rule_files:
  - /etc/prometheus/alerts.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets: ["alertmanager:9093"]

scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ["127.0.0.1:9090"]

  - job_name: app
    metrics_path: /internal/metrics
    static_configs:
      - targets: ["backend:8000"]
        labels: { service: backend }

  - job_name: node
    static_configs:
      - targets: ["node_exporter:9100"]

  - job_name: probe                       # запрос к домену снаружи, как у обычного клиента
    metrics_path: /probe
    params:
      module: [http_2xx]
    static_configs:
      - targets: ["https://example.com/health"]
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: blackbox:9115
```

```yaml
# blackbox/blackbox.yml
modules:
  http_2xx:
    prober: http
    timeout: 5s
    http:
      method: GET
      valid_status_codes: [200]
      preferred_ip_protocol: ip4
```

Три строки `relabel_configs` в задании `probe` меняют местами цель и адрес опроса: Prometheus
обращается к blackbox_exporter, а проверяемый адрес передаёт ему параметром. Без них Prometheus
попытается забрать метрики прямо с домена.

Правила оповещений пишутся на шаге 5, но файл должен существовать **до** первого запуска: на месте
отсутствующего файла Docker создаёт каталог, и Prometheus не стартует, наткнувшись на него.

```bash
echo 'groups: []' > prometheus/alerts.yml
docker compose up -d
```

**Проверка:**

```bash
docker compose ps                                       # все сервисы running
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/-/healthy    # 200

curl -s 'http://127.0.0.1:9090/api/v1/targets?state=active' \
  | grep -o '"health":"[a-z]*"' | sort | uniq -c
```

**Ожидается:** одна строка вида `4 "health":"up"` — по числу целей. Любое `"down"` означает, что
цель недоступна: см. «Типичные отказы».

**Запрос в хранилище** — те же данные, что потом лягут в панели:

```bash
curl -sG http://127.0.0.1:9090/api/v1/query \
  --data-urlencode 'query=sum by (service) (rate(http_requests_total[5m]))' \
  | head -c 400; echo
```

**Ожидается:** `"status":"success"` и хотя бы одно значение в `"result"`. Пустой `"result":[]`
означает, что ряд ещё не появился: подождите два шага опроса и подайте на сервис нагрузку.

---

## Шаг 4. Показ

Источник данных подключается файлом, а не руками в интерфейсе: настройка, сделанная руками, живёт
в томе и теряется при пересоздании установки.

```yaml
# grafana/provisioning/datasources/datasources.yml
apiVersion: 1
datasources:
  - name: Prometheus
    uid: prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
```

```bash
docker compose up -d grafana
```

Доступ — SSH-туннелем с рабочей машины (порт наружу не публикуется):

```bash
ssh -N -L 3000:127.0.0.1:3000 example.com
# в браузере: http://127.0.0.1:3000
```

**Проверка** (выполняется на сервере):

```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/api/health   # 200
curl -s http://127.0.0.1:3000/api/health                                    # "database":"ok"
```

Запросы для панелей — четыре величины из раздела «Что измерять» плюс показатели, специфичные для
системы:

| Панель | Запрос |
|---|---|
| частота запросов | `sum by (service) (rate(http_requests_total[5m]))` |
| доля ошибок | `sum by (service) (rate(http_requests_total{status=~"5.."}[5m])) / sum by (service) (rate(http_requests_total[5m]))` |
| время ответа, 95-й процентиль | `histogram_quantile(0.95, sum by (service, le) (rate(http_request_duration_seconds_bucket[5m])))` |
| время ответа, срединное | `histogram_quantile(0.5, sum by (service, le) (rate(http_request_duration_seconds_bucket[5m])))` |
| занятость пула соединений | `db_pool_connections{state="in_use"} / db_pool_size` |
| очередь сообщений | `sum by (topic) (queue_messages_pending)` |
| свободное место на диске | `node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}` |

Один экран с этими панелями закрывает вопрос «работает ли система сейчас». Разбор конкретного
случая идёт дальше — в журналы.

---

## Шаг 5. Правила оповещений

Правило одно: **оповещение должно требовать действия**. Если на сообщение никто ничего не делает,
его отключают — иначе оно приучает игнорировать и остальные.

**Оповещать по симптому, а не по причине.** «Доля ошибок выше 5 % пять минут подряд» — симптом, его
замечает пользователь. «Загрузка процессора 90 %» — причина, и сама по себе она может быть
нормальной. Из причин в оповещения попадают только те, что неизбежно приводят к отказу: место на
диске, срок сертификата, возраст резервной копии.

**У порога есть время удержания.** Условие должно держаться несколько минут, иначе одиночный
всплеск поднимает тревогу ночью. Кратковременный скачок при выкате — норма.

```yaml
# prometheus/alerts.yml
groups:
  - name: service
    rules:
      - alert: HighErrorRate
        expr: |
          sum by (service) (rate(http_requests_total{status=~"5.."}[5m]))
            / sum by (service) (rate(http_requests_total[5m])) > 0.05
        for: 5m
        labels: { severity: page }
        annotations:
          summary: "доля ошибок {{ $value | humanizePercentage }} у {{ $labels.service }}"
          action: "найти записи журнала за время начала, сверить со временем последнего выката"

      - alert: SlowResponses
        expr: |
          histogram_quantile(0.95,
            sum by (service, le) (rate(http_request_duration_seconds_bucket[5m]))) > 1
        for: 10m
        labels: { severity: ticket }
        annotations:
          summary: "95-й процентиль времени ответа {{ $value }} с у {{ $labels.service }}"
          action: "проверить занятость пула соединений и очередь; смотреть медленные запросы"

      - alert: AppDown
        expr: up{job="app"} == 0
        for: 2m
        labels: { severity: page }
        annotations:
          summary: "сервис не отвечает на опрос метрик"
          action: "docker compose ps и журнал контейнера: не запустился или не готов"

      - alert: ProbeFailed
        expr: probe_success == 0
        for: 3m
        labels: { severity: page }
        annotations:
          summary: "домен не отвечает снаружи: {{ $labels.instance }}"
          action: "проверка контура сверху вниз: домен → шлюз → сервис → база"

  - name: capacity
    rules:
      - alert: DiskFillingUp
        expr: |
          node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
            / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} < 0.15
        for: 30m
        labels: { severity: ticket }
        annotations:
          summary: "свободно {{ $value | humanizePercentage }} на {{ $labels.mountpoint }}"
          action: "docker system df, срок хранения журналов и метрик, старые образы"

      - alert: CertExpiringSoon
        expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 21
        for: 1h
        labels: { severity: ticket }
        annotations:
          summary: "сертификат истекает через {{ $value | humanize }} суток"
          action: "certbot renew --dry-run: автопродление не сработало"

      - alert: BackupStale
        expr: time() - backup_last_success_timestamp_seconds > 26 * 3600
        for: 10m
        labels: { severity: ticket }
        annotations:
          summary: "последняя резервная копия старше суток"
          action: "проверить задание копирования; без свежей копии выкат с миграциями откатывать нечем"

      - alert: QueueGrowing
        expr: min_over_time(queue_messages_pending[30m]) > 1000
        for: 30m
        labels: { severity: ticket }
        annotations:
          summary: "очередь {{ $labels.topic }} не опускалась ниже 1000 за полчаса"
          action: "потребитель медленнее отправителя: см. документ об обмене сообщениями"
```

### Почему такие пороги

| Правило | Порог и удержание | Основание |
|---|---|---|
| `HighErrorRate` | 5 % за 5 мин | единичные `5xx` есть всегда; 5 % — уже заметная доля пользователей. Пять минут отсекают всплеск при переключении копий на выкате |
| `SlowResponses` | 95-й процентиль > 1 с за 10 мин | порог берётся от того, что обещано пользователю, а не от текущего значения. Десять минут отличают медленный хвост от одиночного тяжёлого запроса |
| `AppDown` | 2 мин | штатный перезапуск контейнера укладывается в это время, отказ — нет |
| `ProbeFailed` | 3 мин | проверка идёт по всей цепочке, включая домен и TLS; три минуты покрывают перечитывание конфигурации входа |
| `DiskFillingUp` | 15 % за 30 мин | остатка хватает, чтобы разобраться в рабочее время, а не ночью. Тридцать минут отсекают временные файлы сборки |
| `CertExpiringSoon` | 21 сутки | автопродление начинается за 30 суток; тревога через девять дней после первой неудачной попытки оставляет три недели на разбор |
| `BackupStale` | 26 часов | суточное расписание плюс запас на длительность самого копирования |
| `QueueGrowing` | 1000 за 30 мин, по минимуму окна | минимум за окно, а не мгновенное значение: всплеск, который разобрали, минимум не поднимает |

Пороги — начальные значения, а не измеренные. После двух-трёх недель работы их сверяют с
накопленными рядами: правило, которое ни разу не сработало на настоящем отказе, и правило, которое
срабатывает без действий, одинаково бесполезны.

**Чего здесь намеренно нет** — правила на падение частоты запросов. Порог зависит от суточного и
недельного профиля нагрузки; без накопленной истории он даёт ложные срабатывания каждую ночь.
Правило вводится, когда профиль виден на графике.

**Значение из скрипта** (`backup_last_success_timestamp_seconds`) попадает в метрики через файл,
который читает node_exporter. В скрипт резервного копирования после успешного снимка добавляется:

```bash
D=/var/lib/node-exporter/textfile
printf 'backup_last_success_timestamp_seconds %s\n' "$(date +%s)" > "$D/backup.prom.tmp"
mv "$D/backup.prom.tmp" "$D/backup.prom"      # замена одним действием: без .tmp читается половина файла
```

**Проверка правил:**

```bash
docker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml
curl -s -X POST http://127.0.0.1:9090/-/reload
curl -s http://127.0.0.1:9090/api/v1/rules | grep -o '"name":"[A-Za-z]*"' | sort -u
```

**Ожидается:** `SUCCESS: 8 rules found`, пустой ответ на перезагрузку и десять имён в последнем
выводе — восемь правил и две группы (`service`, `capacity`). Ошибка разбора означает, что правила
**не** загружены, а Prometheus продолжает работать со старым набором.

---

## Шаг 6. Куда уходит оповещение

```yaml
# alertmanager/alertmanager.yml
global:
  resolve_timeout: 5m
  smtp_smarthost: "smtp.example.com:587"
  smtp_from: "admin@example.com"
  smtp_auth_username: "admin@example.com"
  smtp_auth_password_file: /etc/alertmanager/secrets/smtp_password

route:
  receiver: mail-day
  group_by: [alertname, service]     # одно письмо на правило и сервис, а не на каждый ряд
  group_wait: 30s                    # ждём соседние срабатывания, чтобы собрать их в одно письмо
  group_interval: 5m                 # как часто досылать изменения по той же группе
  repeat_interval: 12h               # повтор, пока условие держится
  routes:
    - matchers: ['severity="page"']
      receiver: mail-now
      repeat_interval: 2h

receivers:
  - name: mail-day
    email_configs:
      - to: "admin@example.com"
        send_resolved: true
  - name: mail-now
    email_configs:
      - to: "admin@example.com"
        send_resolved: true

inhibit_rules:                        # сервис лежит — не слать вдобавок про долю ошибок
  - source_matchers: ['alertname="AppDown"']
    target_matchers: ['alertname=~"HighErrorRate|SlowResponses"']
    equal: [service]
```

Почта выбрана как канал, не требующий внешней службы сверх уже имеющегося ящика. Alertmanager
поддерживает и доставку запросом на произвольный адрес (`webhook_configs`) — этим подключается любой
мессенджер или система дежурств, форма настройки та же.

Два получателя различаются не адресом, а частотой повтора: `severity: page` — то, что требует
действия немедленно; `severity: ticket` — то, что разбирается в рабочее время. Метка проставлена в
каждом правиле шага 5.

```bash
docker compose up -d alertmanager
docker compose exec alertmanager amtool check-config /etc/alertmanager/alertmanager.yml
```

**Ожидается:** `SUCCESS` и перечень найденного — маршрут, одно правило подавления, два получателя.
Ошибка означает, что Alertmanager продолжает работать с прежним файлом: он не применяет
конфигурацию, которую не смог разобрать.

---

## Шаг 7. Проверка оповещения искусственным отказом

Проверяются две разные вещи, поэтому проверок две.

**7.1. Путь доставки** — доходит ли письмо вообще. Правило-пустышка срабатывает всегда:

```bash
cat >> prometheus/alerts.yml <<'EOF'
  - name: selftest
    rules:
      - alert: DeliveryTest
        expr: vector(1)
        for: 0m
        labels: { severity: ticket }
        annotations: { summary: "проверка доставки оповещений" }
EOF
curl -s -X POST http://127.0.0.1:9090/-/reload
```

Через минуту:

```bash
docker compose exec alertmanager amtool --alertmanager.url=http://127.0.0.1:9093 alert query
```

**Ожидается:** строка с `DeliveryTest` и письмо на указанном адресе. Тревога есть в Alertmanager, а
письма нет — причина в почте: смотрите `docker compose logs alertmanager`, там видна ошибка
отправки.

Уберите временное правило и перезагрузите настройки — блок добавлялся в конец файла, поэтому
удаляется всё от его заголовка до конца:

```bash
sed -i '/^  - name: selftest$/,$d' prometheus/alerts.yml
docker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml
curl -s -X POST http://127.0.0.1:9090/-/reload
```

**7.2. Измерение** — замечает ли система настоящий отказ. Остановите сервис и дождитесь удержания
`AppDown` (2 минуты):

```bash
cd /opt/app/backend && docker compose stop backend
sleep 180
curl -s http://127.0.0.1:9090/api/v1/alerts | grep -o '"alertname":"[A-Za-z]*"'
docker compose start backend
```

**Ожидается:** `AppDown` в списке, письмо, и через несколько минут после запуска — письмо о
снятии (`send_resolved`).

Остановка боевого сервиса — это простой. Выполняйте проверку на стенде или в окно обслуживания;
если ни того, ни другого нет, ограничьтесь проверкой 7.1: она подтверждает доставку, а условие
правила подтверждается первым же настоящим отказом.

---

## Шаг 8. Ничего не слушается снаружи

```bash
sudo ss -ltnp | grep -E ':(3000|9090|9093)\b'
```

**Ожидается:** во всех строках адрес `127.0.0.1`. Значение `0.0.0.0` или `*` означает, что порт
открыт на всех интерфейсах.

С другой машины:

```bash
curl -m 5 -I http://example.com:3000     # ожидается таймаут или отказ соединения
curl -m 5 -I http://example.com:9090     # то же
```

Ответ на любой из этих запросов означает, что порт опубликован без адреса. Правила `ufw` при этом не
помогут: Docker добавляет свои правила раньше (см. [Docker](./docker-install.md)) — исправлять надо
публикацию в `compose.yml`.

---

## Отметки о выкатах

Всплеск ошибок сопоставляется с обновлением по времени. Чтобы сопоставление занимало секунды, выкат
оставляет след в двух местах.

**Метрика версии.** Сервис отдаёт `app_build_info{service, version}` со значением `1`. Запрос
показывает, когда версия сменилась:

```bash
curl -sG http://127.0.0.1:9090/api/v1/query \
  --data-urlencode 'query=changes(count by (service) (app_build_info)[1h:1m]) > 0'
```

**Отметка на графике.** Скрипт выката добавляет аннотацию в Grafana — вертикальная линия на всех
панелях:

```bash
curl -s -X POST http://127.0.0.1:3000/api/annotations \
  -H 'Content-Type: application/json' \
  -u "admin:$GF_SECURITY_ADMIN_PASSWORD" \
  -d '{"text":"выкат <метка релиза>","tags":["deploy"]}'
```

Строка добавляется в скрипт выката после переключения входа (см.
[Релиз и выкат](./release-and-deploy.md), шаг 6 выката). Значение пароля подставляется из файла
окружения, а не набирается в оболочке: набранное остаётся в её истории. Без отметок вопрос «это
из-за обновления или нет» решается сверкой по журналу выкатов вручную, и в этот момент его обычно
нет под рукой.

---

## Дежурство

Оповещение существует ради действия, поэтому у каждого правила есть адресат и ожидаемое действие —
оно записано в аннотацию `action` (шаг 5). Правило без адресата и действия не заводится.

**Что делать с правилом, на которое не реагируют.** Раз в месяц смотрите, какие правила срабатывали:

```bash
curl -sG http://127.0.0.1:9090/api/v1/query \
  --data-urlencode 'query=sum by (alertname) (count_over_time(ALERTS{alertstate="firing"}[30d]))'
```

Для каждого правила из списка ответ один из трёх:

1. срабатывало, по нему что-то делали — оставить;
2. срабатывало, ничего не делали — **починить**: поднять порог, увеличить удержание, заменить
   причину на симптом;
3. чинить нечего, действие не появится — **удалить**.

Третий вариант — рабочий, а не признак поражения. Игнорируемое оповещение хуже отсутствующего: оно
приучает не смотреть на почту, и вместе с ним игнорируются остальные.

Ночью будят только правила с меткой `severity: page`. Перевод правила в `page` означает, что кто-то
встанет и будет что-то делать; если действия ночью нет, метка `ticket`.

---

## Микросервисы на разных серверах

Когда сервисы стоят на разных машинах, Prometheus не может обращаться к ним по имени контейнера.
Он ходит по частной сети через nginx микросервиса — тот самый вход, который описан в
[Сетевом контуре](./network-topology.md). Публиковать порт сервиса ради опроса нельзя: это создаёт
вход без единой проверки.

В конфигурации nginx микросервиса открывается только путь метрик и только для адреса сборщика:

```nginx
location /internal/metrics {
    allow 10.0.0.20;        # адрес сервера наблюдения в частной сети
    deny all;
    proxy_pass http://backend:8000;
}
```

В настройке Prometheus вместо имени контейнера указывается адрес nginx микросервиса в частной сети:

```yaml
  - job_name: app
    metrics_path: /internal/metrics
    static_configs:
      - targets: ["10.0.0.10:8000"]
        labels: { service: backend }
```

**Проверка** — с сервера наблюдения:

```bash
curl -sf http://10.0.0.10:8000/internal/metrics | head -3   # ожидается: строки метрик
```

С машины, не входящей в частную сеть, тот же запрос должен завершаться отказом соединения.

---

## Разбор отказа

Порядок: **симптом → слой → причина.**

1. Что видит пользователь: ошибка, медленно, недоступно. Отсюда определяется симптом.
2. Найти слой — проверкой контура сверху вниз (домен → шлюз → сервис → база), как описано в
   [Сетевом контуре](./network-topology.md). Первый неотвечающий слой и есть место отказа.
3. В журналах этого слоя найти записи по времени начала, дальше — по идентификатору запроса (см.
   [Журналы](./logs.md)).
4. Сверить со временем последнего выката: совпадение по времени указывает на новую версию как на
   причину.

Запросы, отвечающие на шаг 1 и 2 без открывания интерфейса:

```bash
# когда началось: доля ошибок за последний час
curl -sG http://127.0.0.1:9090/api/v1/query_range \
  --data-urlencode 'query=sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))' \
  --data-urlencode "start=$(date -u -d '1 hour ago' +%s)" \
  --data-urlencode "end=$(date -u +%s)" --data-urlencode 'step=60'

# какие цели не отвечают
curl -sG http://127.0.0.1:9090/api/v1/query --data-urlencode 'query=up == 0' | head -c 400
```

Полезно фиксировать по итогам: что произошло, как обнаружили, что помогло. Это дешевле, чем
проходить тот же путь второй раз, и показывает, какого оповещения не хватало.

---

## Порядок внедрения

Если ставить не всё сразу, порядок по соотношению пользы к трудозатратам:

1. проверка домена снаружи (`blackbox` + правило `ProbeFailed`) — шаги 2–3, 5;
2. [сбор журналов](./logs.md) в одно место с ротацией;
3. ручка метрик и четыре величины по каждому сервису — шаг 1;
4. оповещения на диск, сертификат, возраст резервной копии — шаг 5;
5. сквозной идентификатор запроса в журналах;
6. отметки о выкатах;
7. трассировки — когда предыдущего перестанет хватать.

Первым идёт внешняя проверка: она одна отвечает на вопрос «работает ли система для пользователя» и
не требует правок в приложении.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| цель в состоянии `down`, хотя сервис работает | Prometheus не в сети приложения либо имя сервиса другое | сверить `APP_NETWORK` в `.env` с `docker network ls`; проверить запросом из контейнера |
| цель `down` с ошибкой `404` | путь ручки не совпадает с `metrics_path` | привести `metrics_path` к пути ручки сервиса |
| Prometheus или Grafana не стартует: `permission denied` | вместо именованного тома подставлен каталог хоста | вернуть именованный том; образы работают не от `root` |
| после пересоздания контейнеров графики пусты | данные лежали в контейнере, а не в томе | проверить `volumes` в `compose.yml`, том `prometheus_data` |
| Prometheus занимает всё больше памяти, рядов миллионы | идентификатор попал в метку | убрать метку, оставить шаблон маршрута; ряды исчезнут после срока хранения |
| в Grafana пусто, а в Prometheus данные есть | в панели другой источник данных или другой диапазон времени | сверить `uid` источника и период; повторить тот же запрос через API |
| оповещения приходят постоянно, на них не реагируют | пороги без времени удержания либо оповещения по причинам | оставить симптомы, добавить удержание, лишнее удалить (раздел «Дежурство») |
| тревога есть в Prometheus, письма нет | Alertmanager недоступен или ошибка отправки почты | `amtool alert query`, `docker compose logs alertmanager` |
| на один отказ приходит десяток писем | не задана группировка | `group_by` по `alertname` и `service`, правила подавления |
| отказ заметил пользователь, а не система | нет проверки снаружи | задание `probe` и правило `ProbeFailed` |
| метрики есть, но по ним ничего не понять | смотрят на средние значения | перейти к процентилям (`histogram_quantile`) |
| после выката всё сломалось, причину ищут долго | нет отметки о выкате и версии в метриках | `app_build_info` и аннотация из скрипта выката |
| правила не применились после правки файла | ошибка разбора: Prometheus оставил прежний набор | `promtool check rules`, затем перезагрузка настроек |
| место на диске кончилось из-за метрик | не задан предел по размеру | `--storage.tsdb.retention.size`, снизить срок хранения |

---

## Откат и снятие

```bash
cd /opt/app/observability
docker compose down              # контейнеры и сеть набора; тома остаются
docker compose down -v           # плюс тома: история метрик и настройки Grafana удаляются
```

Что при этом **не затрагивается**: приложение, его тома и его сеть. Наблюдение — отдельный проект
Compose со своими томами, а сеть приложения объявлена как внешняя, поэтому `down` её не удаляет.
Данные приложения находятся вне этого каталога.

**Снять одно правило, не трогая остальное:** убрать его из `alerts.yml` и перезагрузить настройки
(`curl -s -X POST http://127.0.0.1:9090/-/reload`).

**Приостановить оповещения на время работ** — не удаляя правил:

```bash
docker compose exec alertmanager amtool --alertmanager.url=http://127.0.0.1:9093 \
  silence add alertname=HighErrorRate --duration=2h --author=ops --comment="выкат"
```

`--author` обязателен: в контейнере нет учётной записи, из которой amtool взял бы имя сам.

Ручка `/internal/metrics` в сервисе после снятия остаётся: она не нагружает сервис, пока её никто
не опрашивает, и потребуется при следующей установке. Если её нужно закрыть — это правка nginx
микросервиса, а не приложения.

---

Документ: http://docs.gitaspen.ru/development/operations/message-queues

# Обмен сообщениями между сервисами: Kafka

Как довести обмен от «сервисы дёргают друг друга запросами» до состояния «работает брокер, темы
созданы с известными настройками, события публикуются без потерь, неразобранное складывается
отдельно и разбирается». Документ описывает контракт сообщений и постановку Kafka в контейнерах
рядом с приложением.

**Исходное состояние:** сервер с Docker, приложение во внутренней сети Compose, база данных сервиса
работает, брокера нет.

**Что нужно до начала:**

| Условие | Проверка | Ожидается |
|---|---|---|
| Docker и Compose работают | `docker compose version` | `Docker Compose version v2.…` |
| внутренняя сеть приложения существует | `docker network ls` | сеть приложения в списке |
| база сервиса принимает соединения (нужна под таблицу исходящих и отметки об обработанном) | `docker compose exec db pg_isready -U appuser -d appdb` | `accepting connections` |
| свободная память под брокер | `free -g` | не меньше 2 ГБ свободно |
| место под журналы тем | `df -h /var/lib/docker` | запас не меньше расчётного (см. шаг 2) |
| часы сервера синхронизированы | `timedatectl show -p NTPSynchronized --value` | `yes` |

Часы важны потому, что срок хранения тем и отметки времени в сообщениях считаются по системным
часам: расхождение между серверами делает сравнение отметок бессмысленным, а хранение —
непредсказуемым.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| [Docker](./docker-install.md), [сетевой контур](./network-topology.md), [база данных](./database.md) | контракт сообщений, брокер, темы, публикация без потерь, разбор неразобранного | [запуск приложения](./running-an-application.md) с брокером в составе, затем [наблюдение](./observability.md): задержка потребителя и рост тем |

Что предыдущее звено обязано обеспечить:

- **внутреннюю сеть Compose** — брокер общается с сервисами по имени сервиса, портов на хосте не
  публикует. Правило «база данных, очередь — без публикации» задано в
  [сетевом контуре](./network-topology.md);
- **базу с отдельной схемой сервиса** — в ней живут таблица исходящих (шаг 5) и отметки об
  обработанном (шаг 6). Обе таблицы принадлежат сервису и создаются его миграциями;
- **именованные тома под данные** — журналы тем переживают пересоздание контейнера только в томе.

Что этот документ оставляет следующему: адрес брокера в переменных окружения сервисов; темы с
закреплённым числом разделов и сроком хранения; по группе потребителя на сервис — имена групп нужны
наблюдению, чтобы измерять задержку; тему для неразобранного, ненулевое наполнение которой является
поводом для оповещения.

**Порядок принципиален.** Автосоздание тем выключено (шаг 1), поэтому темы создаются до первого
запуска потребителей (шаг 2). Обратный порядок даёт тему с настройками по умолчанию — один раздел,
одна копия — и число разделов после этого можно только увеличить, а срок хранения придётся
исправлять на уже накопленных данных.

---

## Когда очередь, а когда запрос

| Признак | Запрос | Очередь |
|---|---|---|
| нужен ответ немедленно | да | нет |
| отправитель ждёт результат | да | нет |
| получателей может быть несколько | нет | да |
| допустимо выполнить чуть позже | нет | да |
| получатель может быть недоступен | вызов упадёт | сообщение дождётся |

Очередь развязывает сервисы по времени и доступности: отправитель не знает, кто и когда прочитает.
Плата за это — отсутствие немедленного ответа и необходимость учитывать повторную доставку.

## Форма сообщения

Сообщение состоит из трёх частей: заголовки, тело и ключ.

**Заголовки** — служебные поля, одинаковые для всех сообщений:

```json
{
  "message_type": "order_created",
  "entity_id": "A-1024",
  "message_id": "018f3a2c-6f21-7c6a-9a10-2b4f7d5e91c3",
  "timestamp": "2026-01-15T12:34:56Z"
}
```

**Тело** — полезные данные, вложенные в одно поле:

```json
{ "payload": { "items": 3, "total": 1500 } }
```

**Ключ** — идентификатор сущности, к которой относится сообщение.

Правила формы:

- имена полей в едином стиле (`snake_case`), один стиль во всех темах;
- время — в ISO 8601 и в UTC (`Z`), чтобы сообщения от разных серверов сравнивались напрямую;
- тип сообщения — в заголовках, а не выводится из содержимого тела: потребитель решает по нему,
  разбирать ли сообщение вообще;
- `message_id` — уникальный идентификатор самого сообщения, а не сущности. По нему потребитель
  отличает повтор от нового сообщения (шаг 6). Значение назначается один раз, при создании
  сообщения, и не меняется при повторной отправке.

В Kafka заголовки — это список пар «имя — байты». Значения передаются строками в UTF-8; типы в
заголовках не сохраняются, число `3` придёт как `"3"`.

## Ключ и порядок

Ключ определяет, в какой раздел темы попадёт сообщение, а раздел гарантирует порядок. Поэтому
ключом берут идентификатор сущности: все сообщения об одном заказе попадут в один раздел и будут
обработаны по порядку.

Порядок сохраняется **только внутри одного ключа**. Общего порядка по всей теме нет, и рассчитывать
на него нельзя.

Пустой ключ означает произвольное распределение и потерю порядка — допустимо лишь там, где порядок
не важен.

## Темы

Тему называют по её содержанию, а не по отправителю: сменится отправитель — имя останется верным.
Разделение по назначению:

| Тема | Что несёт |
|---|---|
| команды | указание что-то сделать; один потребитель |
| события | сообщение о том, что уже произошло; потребителей может быть много |
| состояния | обновления состояния сущности |
| сигналы присутствия | периодические отметки «жив» |

Событие описывает **свершившийся факт** и формулируется в прошедшем времени. Оно не предписывает
получателю действий: подписчик сам решает, что делать. Это позволяет добавлять новых потребителей,
не трогая отправителя.

## Правила обработки

**Доставка повторяется.** Очередь гарантирует доставку «хотя бы один раз», поэтому одно и то же
сообщение может прийти дважды. Обработчик обязан быть идемпотентным: повторная обработка не создаёт
вторую сущность и не выполняет действие дважды. Проверка — по ключу сообщения или по состоянию
сущности.

**Порядок не гарантирован между ключами.** Обработчик не должен предполагать, что сообщение о
дочерней сущности придёт после сообщения о родительской.

**Неизвестные поля игнорируются.** Отправитель может добавить поле, не согласовывая это с каждым
потребителем; потребитель читает только то, что ему нужно. Так добавление поля перестаёт быть
ломающим изменением.

**Ошибка обработки не теряет сообщение.** Сообщение, которое не удалось обработать, уходит в
отдельную тему для разбора, а не отбрасывается. Иначе сбой в обработчике означает потерю данных.
Как это настраивается — шаг 4.

**Сообщение не содержит секретов.** В очереди оно живёт дольше запроса и доступно всем
потребителям темы. Передаётся идентификатор, по которому получатель запрашивает данные сам.

---

## Шаг 1. Брокер в контейнерах

### Что именно ставится

Kafka версии 4 работает без отдельной службы хранения метаданных: роль, которую раньше исполнял
внешний координатор, вынесена внутрь самой Kafka (режим KRaft). Метаданными управляют узлы с ролью
`controller`; на одном узле обе роли — `broker` и `controller` — совмещаются в одном процессе.
Отдельного контейнера-координатора в схеме нет.

| Установка | Узлов | Роли | Число копий раздела | `min.insync.replicas` |
|---|---|---|---|---|
| один сервер, дев-контур | 1 | `broker,controller` в одном процессе | 1 | 1 |
| требование к доступности | 3 | 3 контроллера, 3 брокера (можно совмещённые) | 3 | 2 |

Одна копия раздела означает: остановка узла — недоступность темы, потеря диска — потеря данных.
Это допустимо там, где сообщение можно переиздать из таблицы исходящих (шаг 5), и недопустимо там,
где очередь является единственным местом хранения факта.

### Описание в Compose

Брокер добавляется в тот же `compose.yml`, что и сервисы, во внутреннюю сеть приложения:

```yaml
services:
  broker:
    image: apache/kafka:4.0.0
    restart: unless-stopped
    environment:
      # --- роли и кворум ---
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:9093
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT

      # --- слушатели ---
      # LISTENERS — что брокер слушает внутри контейнера.
      # ADVERTISED_LISTENERS — адрес, который брокер возвращает клиенту при подключении.
      # Клиент после первого ответа ходит именно по этому адресу, поэтому адрес должен
      # разрешаться на стороне КЛИЕНТА, а не на стороне брокера.
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT

      # --- данные и хранение ---
      KAFKA_LOG_DIRS: /var/lib/kafka/data
      KAFKA_NUM_PARTITIONS: 3               # для тем, созданных без явного указания
      KAFKA_LOG_RETENTION_HOURS: 168        # 7 суток — значение по умолчанию для новых тем
      KAFKA_OFFSETS_RETENTION_MINUTES: 43200   # 30 суток: смещения простаивающих групп не пропадут

      # --- служебные темы на одном узле ---
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0

      # --- автосоздание тем выключено ---
      # Иначе первое обращение к несуществующей теме создаёт её с одним разделом,
      # одной копией и сроком хранения по умолчанию — и опечатка в имени темы
      # молча превращается в новую тему вместо ошибки.
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"

      KAFKA_HEAP_OPTS: -Xmx1g -Xms1g
      CLUSTER_ID: ${KAFKA_CLUSTER_ID}       # см. ниже; задаётся один раз
    volumes:
      - kafka_data:/var/lib/kafka/data
    healthcheck:
      # Инструмент на JVM, запуск занимает несколько секунд — отсюда большой timeout.
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server broker:9092 >/dev/null 2>&1"]
      interval: 15s
      timeout: 10s
      retries: 10
      start_period: 40s
    networks: [app_net]
    # ports не объявлены: брокер доступен только по имени `broker` внутри сети Compose

  app:
    # ...
    environment:
      KAFKA_BOOTSTRAP_SERVERS: broker:9092
    depends_on:
      broker:
        condition: service_healthy
    networks: [app_net]

volumes:
  kafka_data:

networks:
  app_net:
```

Разбор существенных мест:

- **Версия закреплена точным номером** (`4.0.0`, не `latest`). Чтобы пересборка давала тот же образ,
  тег дополняют цифровым отпечатком: `apache/kafka:4.0.0@sha256:…`.
- **Том именованный.** Данные тем в томе переживают пересоздание контейнера; данные в слое
  контейнера исчезают вместе с ним.
- **`ports` отсутствуют** — см. «Почему наружу не публикуется».
- **`depends_on` с условием `service_healthy`.** Без него сервис стартует раньше брокера, первые
  публикации падают по таймауту подключения, а при включённом автосоздании тем ещё и создают темы
  с настройками по умолчанию.
- **`CLUSTER_ID`.** Идентификатор кластера записывается в том при первом запуске. Если переменная
  не задана, образ генерирует случайное значение сам — тогда значение известно только по
  содержимому тома. Явное значение делает первый запуск воспроизводимым.

Идентификатор генерируется один раз и кладётся в файл окружения Compose:

```bash
docker run --rm apache/kafka:4.0.0 /opt/kafka/bin/kafka-storage.sh random-uuid
```

**Ожидается:** строка из 22 символов, например `MkU3OEVBNTcwNTJENDM2Qk`. Значение вписывается в
`.env` рядом с `compose.yml`: `KAFKA_CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk`.

### Права на каталог данных

Процесс в образе работает не от `root`, а от пользователя с идентификатором `1000`. Пустой
именованный том создаётся с владельцем `root`, поэтому при первом запуске брокер может не суметь
записать в него метаданные. Каталог отдаётся владельцу один раз, до первого запуска:

```bash
docker compose run --rm --user 0 --entrypoint sh broker \
  -c 'mkdir -p /var/lib/kafka/data && chown -R 1000:1000 /var/lib/kafka/data'
```

### Запуск и проверка

```bash
docker compose up -d broker
docker compose exec broker /opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server broker:9092
```

**Ожидается:** `Cluster ID: MkU3OEVBNTcwNTJENDM2Qk` — то же значение, что в `.env`. Другой
идентификатор означает, что том был пересоздан и данные прежних тем потеряны.

```bash
docker compose exec broker /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server broker:9092 \
  | head -1
```

**Ожидается:** строка вида `broker:9092 (id: 1 rack: null) -> (` — брокер отвечает и объявляет себя
под тем адресом, по которому к нему пойдут клиенты. Если здесь стоит другое имя, клиенты из соседних
контейнеров подключатся к первому адресу, получат этот и уйдут в таймаут.

```bash
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
```

**Ожидается:** `broker` в состоянии `Up (healthy)`, в колонке портов — `9092/tcp` без стрелки
`0.0.0.0:…->`. Стрелка означает публикацию на хосте.

### Почему наружу не публикуется

Порт брокера не публикуется ни на все интерфейсы, ни «временно, чтобы посмотреть». Причины:

- **в этой конфигурации нет проверки подлинности.** Протокол `PLAINTEXT` не спрашивает у клиента
  ничего: любой, кто дотянулся до порта, читает все темы и пишет в любую из них;
- **перед брокером нельзя поставить обратный прокси.** Kafka — не HTTP: клиент обязан соединяться с
  каждым узлом напрямую по адресу из `advertised.listeners`. Схема «один nginx на входе», описанная
  в [сетевом контуре](./network-topology.md), к брокеру неприменима, и открытый порт не прикрыт
  ничем;
- **правила `ufw` не действуют** на опубликованные Docker порты: свои правила Docker добавляет в
  `iptables` раньше, поэтому порт остаётся открытым снаружи даже при запрещающем правиле
  (см. [Docker](./docker-install.md), шаг 4).

Проверка с другой машины:

```bash
nc -z -w5 example.com 9092; echo $?
```

**Ожидается:** ненулевой код (соединение не установлено). Код `0` означает, что брокер доступен из
интернета — публикацию нужно убрать и считать данные скомпрометированными.

Проверка на самом сервере:

```bash
sudo ss -ltnp | grep 9092
```

**Ожидается:** пустой вывод.

### Когда сервисы на разных серверах

Брокер должен быть доступен сервисам на других серверах — но по частной сети, а не по публичному
адресу. Контейнер брокера помещается в сетевое пространство клиента частной сети тем же приёмом,
что и nginx микросервиса в [сетевом контуре](./network-topology.md), а объявляемый адрес меняется на
адрес частной сети:

```yaml
  broker:
    image: apache/kafka:4.0.0
    network_mode: "service:vpn_client"     # общее сетевое пространство с клиентом частной сети
    environment:
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://10.0.0.20:9092   # адрес в частной сети
      # остальное без изменений
```

Сервисы на других серверах получают `KAFKA_BOOTSTRAP_SERVERS=10.0.0.20:9092`. Адрес в
`advertised.listeners` обязан совпадать с адресом, по которому клиент фактически дотягивается до
брокера: клиент подключается по адресу из `bootstrap`, получает в ответ объявленный адрес и дальше
работает только с ним.

---

## Шаг 2. Темы

### Именование

Имя темы состоит из домена и события в прошедшем времени, разделённых точкой:

```
<домен>.<что произошло>
```

| Пример | Что несёт |
|---|---|
| `orders.created` | заказ создан |
| `orders.cancelled` | заказ отменён |
| `payments.requested` | запрошено списание |
| `payments.completed` | списание завершено |
| `orders.created.dead` | неразобранное из `orders.created` (шаг 4) |

Правила:

- строчные буквы, разделитель между словами — дефис (`orders.payment-method-changed`). Точка
  разделяет уровни, поэтому внутри уровня она не используется;
- имя не содержит ни имени отправителя, ни имени потребителя, ни номера версии сервиса;
- имя темы не переименовывается. Переименование означает новую тему: накопленные сообщения и
  смещения групп остаются у старой;
- в имени допустимы `[a-zA-Z0-9._-]`, длина до 249 символов. Точка и подчёркивание в именах
  одновременно не используются: во внутренних метриках оба знака приводятся к подчёркиванию, и
  `orders.created` с `orders_created` в метриках сольются.

### Число разделов и копий

**Разделы** задают предел параллелизма: сообщения одного раздела обрабатываются строго по очереди,
а разных разделов — независимо. Число копий потребителя в одной группе, работающих одновременно, не
может превышать число разделов; лишние копии простаивают.

| Что определяет | Как выбирать |
|---|---|
| нижняя граница | ожидаемое число одновременно работающих копий потребителя |
| верхняя граница | каждый раздел — отдельные файлы и отдельный поток восстановления; тысячи разделов на узел увеличивают время перезапуска |
| отправная точка | 3 раздела на тему |

Число разделов **можно увеличить, но нельзя уменьшить**. Увеличение меняет распределение ключей по
разделам: сообщения с одним ключом, отправленные до и после изменения, могут попасть в разные
разделы, и порядок между ними теряется. Поэтому число выбирают с запасом сразу.

**Копии раздела** (`replication-factor`) задают, на скольких узлах хранится каждый раздел. На одном
узле возможно только значение 1. На кластере из трёх узлов берут 3 копии и `min.insync.replicas=2`:
при `acks=all` запись подтверждается только после записи на две копии, и остановка одного узла не
останавливает публикацию.

### Срок хранения

Kafka удаляет старые сообщения по сроку, а не после прочтения: прочитанное сообщение остаётся в теме
и доступно другому потребителю.

| Параметр темы | Что задаёт | Значение по умолчанию |
|---|---|---|
| `retention.ms` | сколько хранить по времени | 604800000 (7 суток) |
| `retention.bytes` | предел размера **одного раздела**; `-1` — без предела | `-1` |
| `segment.bytes` | размер отрезка журнала | 1073741824 (1 ГБ) |
| `cleanup.policy` | `delete` — удалять по сроку; `compact` — оставлять последнее значение на ключ | `delete` |

Удаление происходит отрезками: активный отрезок не удаляется, пока не закроется. Поэтому фактический
объём темы превышает расчётный на размер одного активного отрезка на раздел, а тема с малым потоком
может хранить сообщения дольше `retention.ms`.

Расчёт места: `поток × средний размер × срок × число копий раздела`. Для 10 сообщений в секунду по
2 КБ, срока 7 суток и одной копии это `10 × 2048 × 604800 ≈ 12 ГБ` на тему.

**Темы состояний** (по таблице в разделе «Темы») настраиваются на уплотнение —
`cleanup.policy=compact`. Уплотнение оставляет по каждому ключу последнее значение, поэтому тема
перестаёт расти вместе с числом обновлений и остаётся полным снимком состояния. Требования: ключ
обязателен (сообщения без ключа не уплотняются), удаление сущности выражается сообщением с тем же
ключом и пустым телом.

### Создание и просмотр

```bash
docker compose exec broker /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker:9092 \
  --create --topic orders.created \
  --partitions 3 --replication-factor 1 \
  --config retention.ms=604800000 \
  --config cleanup.policy=delete
```

**Ожидается:** `Created topic orders.created.`

Повторный запуск той же команды завершается ошибкой `Topic 'orders.created' already exists` — это
ожидаемо и означает, что тема на месте. Чтобы команду можно было выполнять повторно, добавляют
`--if-not-exists`.

```bash
docker compose exec broker /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker:9092 \
  --describe --topic orders.created
```

**Ожидается:**

```
Topic: orders.created	TopicId: 8Kx1nQ2sTZ6bYw0pL3aRfg	PartitionCount: 3	ReplicationFactor: 1	Configs: cleanup.policy=delete,retention.ms=604800000
	Topic: orders.created	Partition: 0	Leader: 1	Replicas: 1	Isr: 1	Elr:	LastKnownElr:
	Topic: orders.created	Partition: 1	Leader: 1	Replicas: 1	Isr: 1	Elr:	LastKnownElr:
	Topic: orders.created	Partition: 2	Leader: 1	Replicas: 1	Isr: 1	Elr:	LastKnownElr:
```

Проверяется три значения: `PartitionCount` и `ReplicationFactor` совпадают с заданными, а в
`Configs` присутствует `retention.ms`. Пустой `Configs` означает, что тема создана без явных
настроек и живёт на значениях брокера по умолчанию.

Список всех тем:

```bash
docker compose exec broker /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker:9092 --list
```

**Ожидается:** имена созданных тем. Служебные темы (`__consumer_offsets` и подобные) в выводе не
показываются.

Изменение настроек существующей темы:

```bash
# срок хранения
docker compose exec broker /opt/kafka/bin/kafka-configs.sh --bootstrap-server broker:9092 \
  --entity-type topics --entity-name orders.created \
  --alter --add-config retention.ms=2592000000

# число разделов (только в сторону увеличения)
docker compose exec broker /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker:9092 \
  --alter --topic orders.created --partitions 6
```

### Проверка: сообщение публикуется и читается

```bash
docker compose exec -T broker /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server broker:9092 --topic orders.created \
  --property parse.key=true --property key.separator=: <<'EOF'
A-1024:{"payload":{"items":3,"total":1500}}
EOF
```

**Ожидается:** команда завершается без вывода.

```bash
docker compose exec broker /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server broker:9092 --topic orders.created \
  --from-beginning --max-messages 1 \
  --property print.key=true --property print.headers=true
```

**Ожидается:**

```
NO_HEADERS	A-1024	{"payload":{"items":3,"total":1500}}
Processed a total of 1 messages
```

`NO_HEADERS` здесь корректен: консольный отправитель заголовков не ставит. Сообщения, отправленные
приложением, покажут заголовки в виде `message_type:order_created,entity_id:A-1024,…`.

---

## Шаг 3. Потребитель: группа, смещения, повторы

**Группа** — имя, под которым сервис читает тему. Брокер распределяет разделы между копиями одной
группы и хранит для группы позицию чтения (смещение) по каждому разделу. Две разные группы читают
одну тему независимо и получают каждое сообщение обе.

Правила:

- **одно имя группы на сервис**, одинаковое для всех его копий. Имя задаётся явно и не выводится
  из имени хоста, идентификатора контейнера или случайного значения: при перезапуске такое имя
  меняется, группа считается новой и тема перечитывается с начала;
- имя группы совпадает с именем сервиса: `billing-service`, `notifications-service`;
- **число одновременно работающих копий в группе не превышает число разделов** темы;
- если одну тему обрабатывают два **разных** сервиса — это две группы, и так и задумано. Если два
  экземпляра одного сервиса оказались в разных группах — работа выполняется дважды
  (см. «Типичные отказы»).

**Смещение** хранится в брокере, а не в процессе потребителя. Поэтому перезапуск и переезд
потребителя на другой сервер не теряют позицию, а остановка потребителя не приводит к пропуску
сообщений: они дождутся в теме, пока не истечёт срок хранения.

Автоматическая фиксация смещения выключается: она двигает позицию по расписанию, независимо от
того, завершилась ли обработка. Отказ обработчика между автоматическими фиксациями означает
пропущенное сообщение. Смещение двигается вручную — **после** обработки.

```python
# consumer.py — потребитель одной темы
import json
import logging
import os
import time
from confluent_kafka import Consumer

TOPIC = "orders.created"
GROUP = "billing-service"      # одинаково для всех копий сервиса
MAX_ATTEMPTS = 3               # попытки обработки одного сообщения
BACKOFF_BASE = 0.5             # секунды; пауза удваивается с каждой попыткой

consumer = Consumer({
    "bootstrap.servers": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
    "group.id": GROUP,
    "enable.auto.commit": False,      # смещение двигаем сами, после обработки
    "auto.offset.reset": "earliest",  # новая группа читает тему с начала, а не с конца
    "max.poll.interval.ms": 300000,   # предел на обработку одного сообщения — 5 минут
})
consumer.subscribe([TOPIC])

while True:
    msg = consumer.poll(1.0)
    if msg is None:
        continue
    if msg.error():
        logging.error("ошибка чтения: %s", msg.error())
        continue

    for attempt in range(MAX_ATTEMPTS):
        try:
            handle(json.loads(msg.value()))
            break
        except Exception as exc:
            if attempt + 1 < MAX_ATTEMPTS:
                time.sleep(BACKOFF_BASE * 2 ** attempt)
                continue
            to_dead_letter(msg, exc)   # попытки исчерпаны — см. шаг 4

    consumer.commit(msg)   # фиксируется offset + 1: следующее чтение начнётся со следующего
```

Три свойства этого цикла:

- **повторы конечны.** Число попыток ограничено, пауза между ними растёт. Без ограничения
  неразбираемое сообщение повторяется бесконечно и блокирует свой раздел;
- **повтор происходит на месте.** Сообщение не откладывается в конец очереди, поэтому порядок
  внутри раздела сохраняется. Плата — раздел стоит на время повторов (при значениях выше — до
  1,5 с);
- **смещение двигается в обоих исходах** — и после успешной обработки, и после отправки в тему для
  неразобранного. Не двигать смещение при отказе означает вечно перечитывать одно и то же
  сообщение.

`max.poll.interval.ms` — предел времени между обращениями к брокеру. Обработчик, который работает
дольше, приводит к исключению копии из группы и перераспределению разделов; сообщение при этом
достанется другой копии и будет обработано повторно.

**Проверка: потребитель встал на нужное смещение.**

```bash
docker compose exec broker /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server broker:9092 \
  --describe --group billing-service
```

**Ожидается:**

```
GROUP            TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID          HOST         CLIENT-ID
billing-service  orders.created  0          14              14              0    rdkafka-1a2b…        /10.0.1.14   rdkafka
billing-service  orders.created  1          9               9               0    rdkafka-1a2b…        /10.0.1.14   rdkafka
billing-service  orders.created  2          11              11              0    rdkafka-1a2b…        /10.0.1.14   rdkafka
```

Что читается из вывода:

| Колонка | Значение |
|---|---|
| `CURRENT-OFFSET` | докуда группа дочитала |
| `LOG-END-OFFSET` | сколько всего записано в раздел |
| `LAG` | разность — сколько не прочитано |
| `CONSUMER-ID` пустой или `-` | к разделу никто не подключён: потребитель остановлен или копий меньше, чем разделов |

Растущий `LAG` при живом потребителе — см. «Типичные отказы».

---

## Шаг 4. Тема для неразобранного

В брокере нет отдельной сущности «место для неразобранного». Это обычная тема, которую создают
руками, и пишет в неё **потребитель** — после того, как исчерпал попытки обработки. Брокер о её
назначении ничего не знает и никуда сам ничего не перекладывает.

### Создание

Имя — имя исходной темы с суффиксом `.dead`. Одного раздела достаточно: тема читается вручную,
порядок в ней значения не имеет. Срок хранения больше, чем у исходной темы, иначе накопленное
исчезнет раньше, чем до него дойдут руки.

```bash
docker compose exec broker /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker:9092 \
  --create --topic orders.created.dead \
  --partitions 1 --replication-factor 1 \
  --config retention.ms=2592000000        # 30 суток
```

**Ожидается:** `Created topic orders.created.dead.`

Тема создаётся вместе с исходной, а не после первого отказа: при выключенном автосоздании тем
попытка записать в несуществующую тему завершится ошибкой, и сообщение будет потеряно именно в тот
момент, когда его требовалось сохранить.

### Кто и что туда пишет

```python
# продолжение consumer.py
from datetime import datetime, timezone
from confluent_kafka import Producer

producer = Producer({
    "bootstrap.servers": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
    "enable.idempotence": True,
    "acks": "all",
})
DEAD_TOPIC = f"{TOPIC}.dead"


def to_dead_letter(msg, exc: Exception) -> None:
    """Переложить неразобранное сообщение в отдельную тему, не меняя тела."""
    headers = list(msg.headers() or [])
    headers += [
        ("dead-origin-topic",     msg.topic().encode()),
        ("dead-origin-partition", str(msg.partition()).encode()),
        ("dead-origin-offset",    str(msg.offset()).encode()),
        ("dead-consumer-group",   GROUP.encode()),
        ("dead-attempts",         str(MAX_ATTEMPTS).encode()),
        ("dead-error",            f"{type(exc).__name__}: {exc}"[:500].encode()),
        ("dead-at", datetime.now(timezone.utc).isoformat(timespec="seconds").encode()),
    ]
    producer.produce(DEAD_TOPIC, key=msg.key(), value=msg.value(), headers=headers)
    producer.flush(10)      # ждём подтверждения: без него смещение сдвинется раньше записи
    logging.error("сообщение отправлено в %s: %s", DEAD_TOPIC, exc)
```

Существенное:

- **тело не меняется.** В тему кладутся исходные байты, чтобы сообщение можно было переиграть
  как есть. Разбор причины хранится в заголовках, а не подмешивается в тело;
- **ключ сохраняется.** При возврате в исходную тему ключ определит раздел, а значит и порядок;
- **`flush` до фиксации смещения.** Сначала подтверждение от брокера, потом `commit`. Обратный
  порядок теряет сообщение, если процесс остановится между ними;
- **если запись в тему для неразобранного не удалась**, смещение двигать нельзя: сообщение остаётся
  в исходной теме и будет прочитано снова.

Заголовки: `dead-origin-topic`, `dead-origin-partition`, `dead-origin-offset` дают точное место
исходного сообщения; `dead-consumer-group` — какой сервис не справился (одну тему могут читать
несколько групп, и не справиться могла любая); `dead-attempts` и `dead-error` — сколько попыток
сделано и на чём.

### Как оттуда разбирают

Чтение с заголовками:

```bash
docker compose exec broker /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server broker:9092 --topic orders.created.dead \
  --from-beginning --timeout-ms 5000 \
  --property print.headers=true --property print.key=true
```

**Ожидается:** строки вида

```
dead-origin-topic:orders.created,dead-origin-partition:1,dead-origin-offset:57,dead-consumer-group:billing-service,dead-attempts:3,dead-error:KeyError: 'total',dead-at:2026-01-15T12:40:11+00:00	A-1024	{"payload":{"items":3}}
Processed a total of 4 messages
```

Сколько накопилось:

```bash
docker compose exec broker /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server broker:9092 --topic orders.created.dead
```

**Ожидается:** `orders.created.dead:0:4` — в разделе 0 записано 4 сообщения.

Разбор ведётся по `dead-error`: сообщения группируются по причине, а не разбираются поштучно —
одинаковая ошибка на сотне сообщений означает один дефект.

Исходов три:

| Исход | Когда | Что делать |
|---|---|---|
| дефект в обработчике | тело сообщения корректно, обработчик его не принимает | исправить обработчик, выкатить, переиграть сообщения в исходную тему |
| дефект в отправителе | тело сообщения не соответствует контракту | исправить отправителя; для накопленного — переиздать события из таблицы исходящих (шаг 5) или признать невосстановимым |
| восстановление невозможно | сущность уже удалена, событие потеряло смысл | оставить в теме до истечения срока хранения; решение записать |

**Переигрывание** возвращает сообщение в **исходную** тему, а не обрабатывает его прямо из темы для
неразобранного. Иначе обработка раздваивается на два пути, и второй остаётся без тестов и без
наблюдения.

```python
# replay.py — вернуть неразобранное в исходную тему
import os
from confluent_kafka import Consumer, Producer

SOURCE = "orders.created"
DEAD = f"{SOURCE}.dead"
SERVICE_HEADERS = {b"dead-origin-topic", b"dead-origin-partition", b"dead-origin-offset",
                   b"dead-consumer-group", b"dead-attempts", b"dead-error", b"dead-at"}

bootstrap = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
consumer = Consumer({
    "bootstrap.servers": bootstrap,
    "group.id": "replay-tool",         # своя группа: не мешает рабочим потребителям
    "enable.auto.commit": False,
    "auto.offset.reset": "earliest",
})
producer = Producer({"bootstrap.servers": bootstrap, "enable.idempotence": True, "acks": "all"})
consumer.subscribe([DEAD])

moved = 0
while True:
    msg = consumer.poll(5.0)
    if msg is None:
        break
    if msg.error():
        continue
    # служебные заголовки снимаются: при повторном отказе они будут добавлены заново,
    # иначе в сообщении накопятся несколько разных `dead-error`
    headers = [(k, v) for k, v in (msg.headers() or []) if k.encode() not in SERVICE_HEADERS]
    producer.produce(SOURCE, key=msg.key(), value=msg.value(), headers=headers)
    moved += 1

producer.flush(30)
consumer.commit()      # смещение в теме неразобранного двигается только после отправки
print(f"переиграно сообщений: {moved}")
```

Переигрывать следует **после** выката исправления, иначе сообщения вернутся обратно тем же путём.

**Что делать с накопившимся.** Постоянно непустая тема для неразобранного — не «разберём позже», а
работающий контракт, который перестал соблюдаться. Порог оповещения ставится на любое ненулевое
число сообщений в ней (см. [наблюдение](./observability.md)); чем дольше разбор откладывается, тем
вероятнее, что сообщения станут невосстановимыми: сущности удалены, суммы пересчитаны, срок хранения
исходной темы истёк.

---

## Шаг 5. Публикация после фиксации

Событие публикуется **после** того, как изменение зафиксировано в базе. Публикация внутри открытой
транзакции создаёт расхождение: при откате событие уже ушло, и потребители узнают о том, чего не
произошло.

Обратный порядок — сначала запись, потом публикация — оставляет другой риск: процесс может
завершиться между ними, и событие не уйдёт. Где такая потеря недопустима, событие записывают в ту
же транзакцию (отдельной таблицей) и публикуют отдельным шагом, который повторяется до успеха.

| Способ | Что теряется | Когда применим |
|---|---|---|
| публикация внутри транзакции | событие уходит и при откате | не применяется |
| запись, затем публикация из того же кода | событие не уходит, если процесс упал между ними | потеря события допустима: уведомление, метрика |
| таблица исходящих и отдельный публикатор | ничего; возможен повтор | потеря события недопустима |

Третий способ описан ниже. Он даёт доставку «хотя бы один раз»: событие не пропадёт, но может уйти
дважды — на этом и построено требование идемпотентности потребителя (шаг 6).

### Схема таблицы

Таблица создаётся миграцией сервиса, в его схеме (см. [база данных](./database.md), шаг 3), рядом с
предметными таблицами — она обязана попадать в ту же транзакцию.

```sql
CREATE TABLE outbox (
    id           bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    topic        text        NOT NULL,
    message_key  text,
    headers      jsonb       NOT NULL DEFAULT '{}'::jsonb,
    payload      jsonb       NOT NULL,
    status       text        NOT NULL DEFAULT 'pending',
    attempts     integer     NOT NULL DEFAULT 0,
    last_error   text,
    created_at   timestamptz NOT NULL DEFAULT now(),
    available_at timestamptz NOT NULL DEFAULT now(),
    sent_at      timestamptz,
    CONSTRAINT outbox_status_check CHECK (status IN ('pending', 'sent', 'failed'))
);

-- Частичный индекс: в него попадают только неотправленные записи.
-- Таблица растёт, индекс остаётся размером с очередь на отправку.
CREATE INDEX outbox_pending_idx ON outbox (available_at, id) WHERE status = 'pending';
```

| Поле | Назначение |
|---|---|
| `id` | порядок отправки; возрастает вместе с порядком записи |
| `topic` | куда отправлять; хранится в записи, а не выводится из типа события в коде публикатора |
| `message_key` | ключ сообщения — идентификатор сущности (см. «Ключ и порядок») |
| `headers` | служебные поля сообщения, включая `message_id` |
| `payload` | тело сообщения |
| `status` | стадия: см. таблицу ниже |
| `attempts` | сколько раз отправка не удалась; по нему считается пауза до следующей попытки |
| `last_error` | текст последней ошибки отправки |
| `available_at` | момент, раньше которого запись не берут; отодвигается при отказе |
| `sent_at` | когда брокер подтвердил запись; по нему чистится таблица |

| Статус | Что означает | Кто ставит |
|---|---|---|
| `pending` | записано в транзакции с изменением, ещё не отправлено или отправка не удалась | код сервиса при записи; публикатор при откате попытки |
| `sent` | брокер подтвердил запись во все требуемые копии раздела | публикатор |
| `failed` | попытки исчерпаны; требуется вмешательство | публикатор |

Запись события в той же транзакции, что и само изменение:

```sql
BEGIN;

INSERT INTO orders (order_number, total, status)
VALUES ('A-1024', 1500, 'created');

INSERT INTO outbox (topic, message_key, headers, payload)
VALUES ('orders.created', 'A-1024',
        '{"message_type":"order_created","entity_id":"A-1024",
          "message_id":"018f3a2c-6f21-7c6a-9a10-2b4f7d5e91c3",
          "timestamp":"2026-01-15T12:34:56Z"}',
        '{"payload":{"items":3,"total":1500}}');

COMMIT;
```

При откате транзакции откатываются обе вставки: события о несостоявшемся изменении не остаётся.

### Как устроен публикатор

| Размещение | Когда | Что учесть |
|---|---|---|
| фоновая задача в процессе сервиса | один сервис, небольшой поток событий | останавливается вместе с сервисом; при N копиях сервиса работают N публикаторов — блокировка обязательна |
| отдельный процесс (свой контейнер) | поток большой либо публикация не должна конкурировать с обработкой запросов | тот же образ, другая команда запуска; масштабируется и перезапускается отдельно |

В обоих случаях порядок один: **взял → отправил → пометил**.

1. **Взял.** Транзакция открывается, из таблицы выбирается пачка записей `pending` со сроком
   `available_at <= now()`, с блокировкой `FOR UPDATE SKIP LOCKED`.
2. **Отправил.** Записи уходят в брокер, публикатор дожидается подтверждения. Транзакция всё это
   время открыта — блокировка держится, вторая копия эти строки не увидит.
3. **Пометил.** Подтверждённые получают `status='sent'`, неудавшиеся — увеличенный `attempts` и
   отодвинутый `available_at`. Транзакция закрывается, блокировка снимается.

Почему именно такой порядок: пометка **до** отправки теряет событие навсегда, если процесс
остановится между пометкой и отправкой. Пометка **после** отправки в том же случае даёт повтор —
запись останется `pending` и уйдёт второй раз. Повтор поглощается идемпотентным потребителем
(шаг 6), потеря — нет.

**Блокировка.** `FOR UPDATE SKIP LOCKED` заставляет вторую копию публикатора пропустить уже занятые
строки и взять следующие.

| Как выбирать | Что происходит при двух копиях |
|---|---|
| без `FOR UPDATE` | обе копии берут одни и те же строки и отправляют их дважды |
| `FOR UPDATE` без `SKIP LOCKED` | вторая копия ждёт освобождения строк; дублей нет, но работа идёт последовательно |
| `FOR UPDATE SKIP LOCKED` | копии берут непересекающиеся пачки и работают параллельно |

### Пример

```python
# outbox_publisher.py — отправка записей из таблицы исходящих
import json
import logging
import os
import signal
import time

import psycopg
from confluent_kafka import Producer

BATCH = 100          # записей за проход
IDLE_PAUSE = 0.5     # пауза, когда отправлять нечего
MAX_ATTEMPTS = 10    # после этого запись помечается failed и требует вмешательства
FLUSH_TIMEOUT = 30   # ожидание подтверждений от брокера

producer = Producer({
    "bootstrap.servers": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
    "enable.idempotence": True,   # брокер отбрасывает повтор одной и той же записи от продюсера
    "acks": "all",                # подтверждение после записи во все синхронные копии раздела
    "linger.ms": 5,
})

SELECT_BATCH = """
SELECT id, topic, message_key, headers, payload
  FROM outbox
 WHERE status = 'pending' AND available_at <= now()
 ORDER BY id
 LIMIT %s
 FOR UPDATE SKIP LOCKED
"""

MARK_FAILED = """
UPDATE outbox
   SET attempts     = attempts + 1,
       last_error   = %s,
       available_at = now() + make_interval(secs => least(300, power(2, attempts)::int)),
       status       = CASE WHEN attempts + 1 >= %s THEN 'failed' ELSE 'pending' END
 WHERE id = %s
"""


def publish_batch(conn) -> int:
    with conn.transaction():                       # блокировка держится до конца транзакции
        with conn.cursor() as cur:
            cur.execute(SELECT_BATCH, (BATCH,))
            rows = cur.fetchall()
            if not rows:
                return 0

            results: dict[int, str | None] = {}

            def on_delivery(err, _msg, row_id):
                results[row_id] = str(err) if err else None

            for row_id, topic, key, headers, payload in rows:
                producer.produce(
                    topic=topic,
                    key=key.encode() if key else None,
                    value=json.dumps(payload).encode(),
                    headers=[(k, str(v).encode()) for k, v in headers.items()],
                    on_delivery=lambda err, msg, rid=row_id: on_delivery(err, msg, rid),
                )

            producer.flush(FLUSH_TIMEOUT)           # после этого исход каждой записи известен

            sent = [rid for rid, err in results.items() if err is None]
            failed = {rid: err for rid, err in results.items() if err is not None}

            if sent:
                cur.execute(
                    "UPDATE outbox SET status = 'sent', sent_at = now() WHERE id = ANY(%s)",
                    (sent,),
                )
            for row_id, err in failed.items():
                cur.execute(MARK_FAILED, (err[:500], MAX_ATTEMPTS, row_id))

            # Записи без подтверждения за FLUSH_TIMEOUT остаются pending и будут взяты
            # следующим проходом: отсюда возможен повтор отправки.
            return len(rows)


running = True


def stop(*_):
    global running
    running = False


signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)

with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
    while running:
        try:
            taken = publish_batch(conn)
        except Exception:
            logging.exception("проход публикатора не удался")
            taken = 0
        if taken < BATCH:            # взяли меньше пачки — очередь разобрана, ждём
            time.sleep(IDLE_PAUSE)

producer.flush(FLUSH_TIMEOUT)
```

Существенное:

- **`enable.idempotence` и `acks=all`** у отправителя. Первое отсекает повтор, возникший при
  внутреннем повторе отправки в клиенте; второе означает, что подтверждение приходит после записи во
  все синхронные копии раздела. Без `acks=all` подтверждение приходит от одного узла, и потеря узла
  теряет подтверждённое сообщение.
- **Пачка ограничена.** Транзакция открыта всё время отправки; чем больше пачка, тем дольше она
  держит блокировки и соединение.
- **Пауза до следующей попытки растёт** (`power(2, attempts)`) и ограничена 300 секундами. Без
  ограничения недоступный брокер превращается в цикл без пауз, который нагружает базу.
- **`failed` не отправляется автоматически.** Запись в этом статусе означает, что дело не в
  доступности брокера: несуществующая тема, сообщение больше предельного размера, неверный формат.
  После устранения причины записи возвращают в работу: `UPDATE outbox SET status='pending',
  attempts=0, available_at=now() WHERE status='failed'`.

**Проверка публикатора:**

```sql
SELECT status, count(*), min(created_at) AS oldest FROM outbox GROUP BY status;
```

**Ожидается:** `pending` — единицы записей с недавним `oldest`, `failed` — ноль. Растущий `pending`
со старым `oldest` означает, что публикатор не работает; ненулевой `failed` требует разбора по
`last_error`.

### Очистка таблицы

Отправленные записи не нужны, но удаляются не сразу: они нужны, чтобы восстановить события, если
понадобится переиздание. Срок хранения берут равным сроку хранения тем.

```sql
DELETE FROM outbox WHERE status = 'sent' AND sent_at < now() - interval '7 days';
```

Запрос ставится в суточное расписание. Без очистки таблица растёт неограниченно; частичный индекс
при этом не растёт, поэтому отправка не замедляется — замедляются резервное копирование и
обслуживание таблицы.

---

## Шаг 6. Идемпотентность потребителя

Общее правило и способы его выполнения описаны в
[BMBP](../architecture/BMBP.md), раздел «Идемпотентность». Здесь — только то, что относится к
очереди: где хранится отметка об обработанном сообщении и когда её чистят.

Отметка нужна там, где по состоянию сущности повтор не отличить: обработка не меняет состояние (
отправка уведомления), или меняет его накопительно (начисление, списание, счётчик). Там, где повтор
виден по состоянию — заказ уже не в статусе «ждёт оплаты», файл уже создан — отдельная отметка не
нужна, и проверка делается по состоянию.

```sql
CREATE TABLE processed_messages (
    consumer_group text        NOT NULL,
    topic          text        NOT NULL,
    message_id     text        NOT NULL,
    processed_at   timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (consumer_group, topic, message_id)
);

CREATE INDEX processed_messages_processed_at_idx ON processed_messages (processed_at);
```

Таблица живёт **в схеме сервиса-потребителя**, а не в общей: отметка принадлежит тому, кто
обрабатывал. Имя группы входит в ключ, потому что одну тему могут читать несколько сервисов, и для
каждого повтор считается отдельно.

Откуда берётся `message_id`:

| Источник | Свойства |
|---|---|
| заголовок `message_id` | переживает переигрывание из темы для неразобранного: идентификатор остаётся прежним |
| тройка «тема, раздел, смещение» | доступна всегда, менять контракт не требуется; у переигранного сообщения будет другое смещение, и повтор не распознается |

Отметка ставится **в той же транзакции, что и результат обработки**:

```python
def handle(message_id: str, data: dict) -> None:
    with conn.transaction():
        rows = conn.execute(
            "INSERT INTO processed_messages (consumer_group, topic, message_id) "
            "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING RETURNING 1",
            (GROUP, TOPIC, message_id),
        ).fetchall()
        if not rows:
            return          # сообщение уже обработано — повтор, выходим без работы
        apply_business_change(data)
```

Порядок «вставка отметки, затем работа» внутри одной транзакции: при откате откатывается и отметка,
поэтому «отметка есть, работа не выполнена» невозможно. Отметка в отдельной транзакции даёт именно
это расхождение.

**Когда чистят.** Отметка нужна ровно столько, сколько сообщение может быть доставлено повторно, то
есть пока оно лежит в теме. Срок хранения отметок берут вдвое больше срока хранения темы — запас
покрывает переигрывание и остановленного потребителя:

```sql
DELETE FROM processed_messages WHERE processed_at < now() - interval '14 days';
```

Запрос ставится в суточное расписание. Без очистки таблица растёт вместе с общим числом сообщений
за всё время, а её единственный индекс — вместе с ней.

**Проверка:**

```sql
SELECT count(*) FROM processed_messages WHERE processed_at < now() - interval '14 days';
```

**Ожидается:** `0`. Ненулевое значение означает, что очистка не выполняется.

---

## Шаг 7. Проверка контура целиком

Проверка идёт снизу вверх; первый шаг, который не даёт ожидаемого результата, и есть место отказа.

```bash
# 1. брокер отвечает
docker compose exec broker /opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server broker:9092

# 2. темы созданы с нужными настройками
docker compose exec broker /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker:9092 \
  --describe --topic orders.created

# 3. сервис записал событие в таблицу исходящих и публикатор его отправил
docker compose exec db psql -U appuser -d appdb -c \
  "SELECT status, count(*) FROM outbox GROUP BY status"

# 4. сообщение лежит в теме
docker compose exec broker /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server broker:9092 --topic orders.created

# 5. потребитель его прочитал
docker compose exec broker /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server broker:9092 \
  --describe --group billing-service

# 6. неразобранного нет
docker compose exec broker /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server broker:9092 --topic orders.created.dead

# 7. порт брокера снаружи не слушается (с другой машины)
nc -z -w5 example.com 9092; echo $?
```

| Шаг | Ожидается |
|---|---|
| 1 | `Cluster ID: …` — значение из `.env` |
| 2 | `PartitionCount: 3`, `ReplicationFactor: 1`, в `Configs` есть `retention.ms` |
| 3 | только `sent`; `pending` — единицы, `failed` отсутствует |
| 4 | `orders.created:0:…` по каждому разделу, сумма растёт после публикации |
| 5 | `LAG` равен 0 или уменьшается; `CONSUMER-ID` заполнен по всем разделам |
| 6 | `orders.created.dead:0:0` |
| 7 | ненулевой код возврата |

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| `LAG` растёт, потребитель живой | обработчик медленнее потока; часто — синхронный вызов внешнего сервиса внутри обработчика | увеличить число копий потребителя (не больше числа разделов), при упоре в число разделов — увеличить разделы; вынести медленный вызов из обработчика |
| `LAG` растёт, `CONSUMER-ID` пустой | потребитель остановлен или упал | `docker compose ps`, `docker compose logs`; проверить, что имя группы не менялось |
| одно сообщение обрабатывается бесконечно, раздел стоит | нет предела числа попыток: обработчик падает, смещение не двигается, сообщение читается снова | ограничить попытки (шаг 3), после исчерпания — в тему для неразобранного; для уже застрявшего сдвинуть смещение вручную (см. ниже) |
| группа перечитывает тему с начала после простоя | смещения удалены по `offsets.retention.minutes` (по умолчанию 7 суток) | поднять `offsets.retention.minutes`; восстановить позицию через `--reset-offsets --to-datetime` |
| группа читает с начала при первом запуске | новая группа и `auto.offset.reset=earliest` | это ожидаемо; чтобы начать с текущего момента, до запуска задать смещение через `--reset-offsets --to-latest` |
| диск заполнен журналами тем | у темы нет `retention.ms` или он рассчитан без учёта потока; `retention.bytes` задан на раздел, а не на тему | найти крупные темы (`du` внутри контейнера); снизить `retention.ms`; помнить, что активный отрезок не удаляется до закрытия |
| работа выполняется дважды, два потребителя читают одно | копии одного сервиса оказались в разных группах: `group.id` собран из имени хоста, идентификатора контейнера или случайного значения | задать `group.id` явно и одинаково для всех копий; проверить `kafka-consumer-groups.sh --list` — лишние похожие имена видны сразу |
| тема существует, но с одним разделом и без срока хранения | тема создана автоматически при первом обращении, до того как её создали руками | выключить `auto.create.topics.enable`; число разделов увеличить (`--alter --partitions`), срок задать через `kafka-configs.sh`; уменьшить число разделов нельзя — только пересоздать тему |
| потребитель подключается, затем таймаут | `advertised.listeners` объявляет адрес, недоступный клиенту | привести объявляемый адрес к тому, по которому клиент реально дотягивается (шаг 1) |
| брокер не стартует: отказ записи в каталог данных | том создан с владельцем `root`, процесс работает от пользователя `1000` | сменить владельца тома (шаг 1) |
| брокер не стартует: несоответствие идентификатора кластера | `CLUSTER_ID` изменён при существующем томе | вернуть прежнее значение; смена идентификатора требует пустого тома и означает потерю данных |
| отправка падает: `Not enough in-sync replicas` | `acks=all` при числе синхронных копий меньше `min.insync.replicas` | проверить, что все узлы кластера работают; на одном узле `min.insync.replicas` должен быть равен 1 |
| копии потребителя постоянно переподключаются, сообщения обрабатываются повторно | обработка одного сообщения дольше `max.poll.interval.ms` | сократить работу в обработчике либо поднять `max.poll.interval.ms`; проверить, не ждёт ли обработчик внешний сервис без таймаута |
| действие выполнено дважды | обработчик не идемпотентен | проверка по `message_id` или по состоянию сущности (шаг 6) |
| сообщения обрабатываются не по порядку | разные ключи или пустой ключ | ключом брать идентификатор сущности |
| потребитель не видит сообщений | подписан на другую тему или другую группу | сверить имя темы и группы; `kafka-topics.sh --list` покажет опечатку в имени |
| потребитель падает на новом поле | строгий разбор сообщения | игнорировать неизвестные поля |

Сдвинуть смещение застрявшей группы (группа должна быть остановлена — при живых участниках команда
откажет):

```bash
docker compose stop app
docker compose exec broker /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server broker:9092 \
  --group billing-service --topic orders.created \
  --reset-offsets --shift-by 1 --execute
docker compose start app
```

Доступные варианты: `--shift-by N` (сдвиг на N сообщений), `--to-offset N`, `--to-earliest`,
`--to-latest`, `--to-datetime 2026-01-15T00:00:00.000`. Без `--execute` команда только показывает,
что сделает.

Журналы: `docker compose logs broker` — запуск брокера, выборы контроллера, ошибки записи на диск;
`docker compose logs app` — отказы обработчиков и публикатора.

---

## Откат и снятие

**Остановить потребителя, не потеряв смещение.** Смещения хранятся в брокере, а не в процессе, и
остановка их не трогает:

```bash
docker compose stop app          # SIGTERM: потребитель успевает зафиксировать смещение
docker compose exec broker /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server broker:9092 \
  --describe --group billing-service
```

**Ожидается:** группа в выводе, `CURRENT-OFFSET` заполнен, `CONSUMER-ID` пуст. Это означает, что
позиция сохранена и при запуске чтение продолжится с неё.

Условия сохранения позиции:

- остановка штатная (`stop`, а не `kill -9`): при принудительном завершении не зафиксированные
  смещения теряются, и обработанные сообщения будут прочитаны повторно — их поглотит проверка
  идемпотентности;
- простой короче `offsets.retention.minutes`. Дольше — смещения удаляются, и группа при запуске
  начнёт согласно `auto.offset.reset`;
- сообщения дожидаются потребителя не дольше `retention.ms` темы. Простой длиннее срока хранения
  означает пропуск сообщений независимо от смещений.

**Удалить тему:**

```bash
docker compose exec broker /opt/kafka/bin/kafka-topics.sh --bootstrap-server broker:9092 \
  --delete --topic orders.created
```

Удаление возвращает управление сразу, файлы удаляются асинхронно. Подтверждение — тема исчезла из
`--list`.

Что при удалении темы **не удаляется**:

| Остаётся | Где | Почему это важно |
|---|---|---|
| смещения групп, читавших тему | служебная тема брокера | пересозданная тема с тем же именем будет читаться со старых смещений, а они больше не соответствуют содержимому |
| тема для неразобранного | отдельная тема | удаляется отдельной командой |
| таблица исходящих | база сервиса | записи `pending` продолжат уходить в удалённую тему; при выключенном автосоздании отправка будет падать |
| отметки об обработанном | база сервиса | чистятся по расписанию (шаг 6) |
| подписка потребителей | конфигурация сервисов | потребители продолжат опрашивать несуществующую тему и писать в журнал ошибки |

Поэтому снятие темы выполняется в порядке: остановить потребителей → убрать тему из конфигурации
сервисов → удалить группу → удалить тему.

```bash
docker compose exec broker /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server broker:9092 \
  --delete --group billing-service
```

Команда работает только при отсутствии активных участников группы.

**Снять брокер целиком:**

```bash
docker compose stop broker      # данные в томе сохраняются
docker compose rm -f broker
```

Том с журналами тем при этом остаётся: повторный запуск с тем же `CLUSTER_ID` продолжит работу с
накопленными данными. Удаление данных — отдельное действие:

```bash
docker volume rm <проект>_kafka_data
```

Оно необратимо и уничтожает все темы, сообщения и смещения. Восстановить их можно только повторной
публикацией из таблиц исходящих — и только те события, которые в них ещё не очищены.

---

Документ: http://docs.gitaspen.ru/development/operations/backup-and-restore

# Резервное копирование и восстановление

Что копировать, куда, как проверять и как восстанавливаться. Документ исходит из того, что резервная
копия существует не сама по себе, а ради восстановления: непроверенная копия равнозначна её
отсутствию.

**Что нужно до начала:** работающий сервер с приложением, доступ к хранилищу вне этого сервера.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| приложение выкатывается и работает | что копируем, куда, как восстанавливаем и как это проверяется | наблюдение за системой, включая контроль свежести копий |

Что предыдущее звено обязано обеспечить: перед миграциями базы делается снимок — выкат с
изменением схемы без свежей копии не имеет пути назад.

---

## Что копировать, а что нет

| Данные | Копировать | Почему |
|---|---|---|
| база данных | да | единственный экземпляр, пересоздать нельзя |
| загруженные файлы (тома) | да | то же самое |
| секреты и файлы окружения | да | не лежат в репозитории; без них восстановленная система не запустится |
| конфигурация сервера вне репозитория | да | воспроизводится по памяти дорого |
| исходный код | нет | лежит в репозитории |
| образы контейнеров | нет | пересобираются из кода |
| логи | обычно нет | восстановлению не помогают; хранятся отдельно и недолго |

Проверка полноты — мысленный опыт: сервер уничтожен целиком. Что нужно, чтобы поднять систему
заново? Всё из этого списка должно быть в копии либо в репозитории.

---

## Куда класть

Копия хранится **вне сервера, который копируется**. Копия на том же диске защищает только от
случайного удаления файла и не защищает ни от отказа диска, ни от потери доступа к серверу, ни от
шифровальщика.

Минимально достаточная схема: свежие копии на сервере (быстрое восстановление) + отправка во
внешнее хранилище (защита от потери сервера).

Доступ к внешнему хранилищу выдаётся отдельным ключом только на запись и чтение своего каталога.
Ключ с правом удаления, лежащий на сервере, означает, что тот, кто получил сервер, удалит и копии.

---

## Как копировать базу

Логический снимок переносим между версиями и читаем человеком:

```bash
pg_dump --format=custom --no-owner --no-privileges "$DATABASE_URL" > db-$(date -u +%Y%m%dT%H%M%SZ).dump
```

- `--format=custom` — сжатый формат, из которого можно восстановить выборочно;
- `--no-owner`, `--no-privileges` — снимок не тянет за собой пользователей и права конкретной
  установки, поэтому восстанавливается на другом сервере.

Снимать копию с работающей базы допустимо: команда видит согласованное состояние на момент старта.

Если база работает в контейнере, снимок делается клиентом той же основной версии — иначе формат
может оказаться несовместим.

**Проверка:** файл ненулевого размера и читается инструментом восстановления:

```bash
ls -lh db-*.dump
pg_restore --list db-*.dump | head     # ожидается перечень объектов, а не ошибка формата
```

---

## Как копировать файлы и секреты

Тома контейнеров и каталоги с секретами — архивом, права и владельцы сохраняются:

```bash
tar -czf files-$(date -u +%Y%m%dT%H%M%SZ).tar.gz -C /var/lib/app uploads
tar -czf secrets-$(date -u +%Y%m%dT%H%M%SZ).tar.gz -C /opt/app .secrets
```

Архив с секретами шифруется до отправки во внешнее хранилище: там он лежит долго и доступен всем,
у кого есть доступ к хранилищу.

```bash
gpg --symmetric --cipher-algo AES256 secrets-*.tar.gz     # результат: .gpg, исходный файл удалить
```

Пароль шифрования хранится **не на этом сервере** — иначе он теряется вместе с ним.

---

## Расписание и срок хранения

Частота определяется тем, потерю какого объёма работы вы готовы принять: суточная копия означает,
что в худшем случае теряются сутки.

Срок хранения задаётся так, чтобы пережить незамеченную порчу данных. Если ошибка обнаруживается
через неделю, а копии хранятся три дня, восстанавливать будет нечего. Распространённая схема:
ежедневные копии за последние две недели плюс еженедельные за несколько месяцев.

Старые копии удаляются автоматически: ручная чистка либо не делается, либо однажды удаляет нужное.

---

## Проверка восстановлением

Единственное доказательство, что копия рабочая, — восстановление из неё. Проверка делается
регулярно и **не на боевой машине**.

```bash
# 1. поднять пустую базу для проверки
docker run --rm -d --name restore-check -e POSTGRES_PASSWORD=check -p 15432:5432 postgres:18-alpine

# 2. восстановить копию
pg_restore --clean --if-exists --no-owner -d "postgresql://postgres:check@127.0.0.1:15432/postgres" db-*.dump

# 3. убедиться, что данные на месте
psql "postgresql://postgres:check@127.0.0.1:15432/postgres" -c "\dt"
psql "postgresql://postgres:check@127.0.0.1:15432/postgres" -c "SELECT count(*) FROM <ключевая таблица>"

# 4. убрать проверочную базу
docker rm -f restore-check
```

**Ожидается:** восстановление без ошибок, таблицы на месте, число записей близко к боевому.

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

---

## Восстановление

Порядок при потере сервера:

1. поднять чистый сервер, поставить Docker;
2. получить копии из внешнего хранилища;
3. расшифровать и распаковать секреты и файлы;
4. развернуть код из репозитория, собрать образы;
5. поднять базу, восстановить снимок;
6. запустить приложение, проверить служебные ручки;
7. направить домен на новый сервер, выпустить сертификат.

Порядок важен: приложение стартует после восстановления базы, а домен переключается последним,
когда система уже отвечает.

**Частичное восстановление** — когда потеряны отдельные данные, а система работает. Снимок в
пользовательском формате позволяет восстановить одну таблицу, не трогая остальные:

```bash
pg_restore --data-only --table=<таблица> -d "$DATABASE_URL" db-*.dump
```

Перед этим стоит сделать копию текущего состояния: восстановление поверх живых данных необратимо.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| копия создаётся, но пустая | у пользователя нет прав на чтение данных | проверить права; контролировать размер файла, а не факт запуска |
| `pg_restore` не читает файл | снимок снят клиентом новее сервера | использовать клиент той же основной версии |
| восстановление падает на правах и владельцах | снимок снят без `--no-owner` | пересоздать снимок с нужными флагами |
| копий нет за последние недели | задание перестало выполняться молча | оповещение по возрасту последней копии |
| копии есть, но не восстанавливаются | никогда не проверялись | ввести регулярную проверку восстановлением |
| копии удалены вместе с сервером | ключ доступа к хранилищу разрешал удаление | ключ только на запись; хранение с защитой от удаления |
| восстановили, приложение не стартует | не скопированы секреты и окружение | включить их в копию и в проверку |

---

Документ: http://docs.gitaspen.ru/development/operations/release-and-deploy

# Релиз и выкат без простоя

Как собрать воспроизводимый релиз и выкатить его так, чтобы пользователи не увидели перерыва, а
неудачный выкат откатывался за секунды.

**Исходное состояние:** работающий контур (домен → вход → сервисы) с одной копией приложения.
**Результат:** две копии, артефакт выпуска с контрольными суммами, выкат и откат одной командой.

Порядок реализован двумя скриптами; документ объясняет, что они делают и почему именно так:

| Скрипт | Где запускается | Что делает |
|---|---|---|
| [`tools/build-release.sh`](../../tools/build-release.sh) | машина сборки | проверки, образы, состав поставки, каталог выпуска |
| [`tools/deploy.sh`](../../tools/deploy.sh) | сервер (кроме `push`) | перенос, загрузка, подъём копии, миграции, переключение, откат |

Оба скрипта — образцы для копирования в проект. Параметры вынесены в шапку каждого файла:
имена образов, список файлов времени выполнения, имена служб, адреса проверки готовности. Ниже по
тексту скрипты эти параметры только читают, так что правится одно место.

## Предусловия

| Условие | Проверка | Ожидается |
|---|---|---|
| Docker и Compose на сервере и на машине сборки | `docker compose version` | `Docker Compose version v2.…` |
| контур работает через вход | `curl -sfI https://example.com/` | `HTTP/2 200` |
| у сервиса есть ручка готовности | `curl -sf https://example.com/readyz` | код 200, в теле — версия |
| каталог установки на сервере | `ls /srv/app/compose.prod.yml` | путь напечатан |
| инструменты сборки | `git --version && gitleaks version && docker scout version` | три строки с версиями |
| резервная копия базы восстанавливается | [резервное копирование](./backup-and-restore.md) | проверка восстановлением пройдена |

Последнее условие — не формальность: выкат с миграциями без проверенной копии не имеет пути назад.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| работающий контур: домен → вход → сервисы; [база](./database.md) со схемой и таблицей версий; [проверенные копии](./backup-and-restore.md) | сборка релиза, выкат, откат | [наблюдение](./observability.md) за выкаченной версией |

Что предыдущее звено обязано обеспечить:

- у каждого сервиса есть ручка готовности, по которой видно, что он может принимать трафик. Без
  неё выкат становится «подождать и надеяться»;
- миграции применяет один исполнитель, а не старт каждой копии — требование
  [базы данных](./database.md), и оно определяет порядок шагов выката;
- секреты лежат на сервере и подставляются при запуске, а не собираются в образ
  ([секреты](./secrets.md)).

Что этот документ оставляет следующему: у каждой версии есть метка выпуска, манифест с
идентификаторами образов и состав поставки — по ним наблюдение отвечает на вопрос «что именно
сейчас работает» и «затрагивает ли новая уязвимость эту версию».

---

## Идея

Две одинаковые копии приложения — «синяя» (blue) и «зелёная» (green). Трафик в каждый момент идёт
только в одну. Новая версия поднимается в свободной копии, проверяется, и лишь затем на неё
переключается вход. Прежняя копия остаётся запущенной — откат означает вернуть указатель обратно.

```
             ┌── синяя  (принимает трафик)
вход (nginx) ┤
             └── зелёная (поднята с новой версией, проверяется)
```

Копии называются по цвету, а не «старая» и «новая»: имя не должно зависеть от того, какая версия в
ней сейчас. После каждого выката цвета меняются ролями, поэтому «новая копия» — это состояние, а не
название.

Что это даёт: пользователь не видит перезапуска; проверка идёт на настоящей рабочей среде, а не на
стенде; откат не требует пересборки.

Чего это не даёт: несовместимые изменения схемы данных так не откатываются — база общая для обеих
копий. Об этом отдельный раздел ниже.

---

## Как устроены две копии

Копия — это набор служб с суффиксом цвета. Постоянная часть одна: вход, который держит порт наружу
и при выкате не пересоздаётся.

```yaml
# compose.prod.yml
x-app: &app
  image: example-app:prod
  build: ./backend
  restart: unless-stopped
  environment:
    APP_DATABASE_URL: ${APP_DATABASE_URL:?переменная не задана}

x-app-gateway: &app-gateway
  image: example-web-gateway:prod
  build: ./gateway
  restart: unless-stopped

x-web: &web
  image: example-web:prod
  build: ./frontend
  restart: unless-stopped

services:
  # Постоянная часть: держит порт, разводит трафик по активному цвету.
  edge:
    image: nginx:alpine@sha256:<digest>
    ports: ['127.0.0.1:8080:80']
    volumes:
      - ./edge/conf.d:/etc/nginx/conf.d:ro
    networks: [edge-net]
    restart: unless-stopped

  app-blue:          { <<: *app,         networks: [app-blue-net, data-net] }
  app-green:         { <<: *app,         networks: [app-green-net, data-net] }
  app-gateway-blue:  { <<: *app-gateway, networks: [edge-net, app-blue-net],  environment: { APP_UPSTREAM: app-blue:8080 } }
  app-gateway-green: { <<: *app-gateway, networks: [edge-net, app-green-net], environment: { APP_UPSTREAM: app-green:8080 } }
  web-blue:          { <<: *web,         networks: [edge-net] }
  web-green:         { <<: *web,         networks: [edge-net] }

  # Разовый исполнитель миграций: профиль tools не поднимается вместе со стеком.
  migrate:
    image: example-app:prod
    profiles: ['tools']
    command: ['/app/migrate', 'up']
    environment:
      APP_DATABASE_URL: ${APP_DATABASE_URL:?переменная не задана}
    networks: [data-net]
    restart: 'no'

networks:
  edge-net:
  app-blue-net:
  app-green-net:
  data-net:
    internal: true
```

Портов нет ни у одной цветной службы: наружу смотрит только вход
([сетевой контур](./network-topology.md)). Поэтому и проверка готовности выполняется изнутри — из
контейнера входа, а не с рабочей машины.

**Где записан активный цвет.** В отдельном подключаемом файле, а не в основной конфигурации:

```nginx
# edge/conf.d/active.inc — единственное, что меняется при переключении
# active-color: blue
set $app_upstream app-gateway-blue;
set $static_upstream web-blue;
```

Расширение `.inc`, а не `.conf`: основная конфигурация nginx подключает `conf.d/*.conf`, и файл с
директивами `set` вне `server`-блока сломал бы запуск, попади он под эту маску.

```nginx
# edge/conf.d/gateway.conf — постоянная часть, при выкате не меняется
server {
  listen 80;
  server_name example.com;

  # Встроенный DNS Docker: имена служб разрешаются на каждом запросе.
  resolver 127.0.0.11 valid=5s;
  include /etc/nginx/conf.d/active.inc;

  location /api/ {
    proxy_pass http://$app_upstream;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
  }

  location / {
    proxy_pass http://$static_upstream;
  }
}
```

Переменная в `proxy_pass` обязательна. Без переменной nginx разрешает имя upstream один раз при
старте и отказывается запускаться, если контейнера этого цвета сейчас нет. С переменной имя
разрешается на каждом запросе через встроенный DNS Docker — отсюда строка `resolver 127.0.0.11`.

**Проверка:**

```bash
docker compose -f compose.prod.yml up -d
./tools/deploy.sh status
```

**Ожидается:** `активный цвет: blue`, `свободный цвет: green`, ниже — список служб, где запущены обе
копии и вход.

---

## Сборка релиза

Релиз собирается из **зафиксированного** состояния кода: сборка из рабочего каталога с
незакоммиченными правками невоспроизводима — по образу нельзя понять, что в нём.

```bash
./tools/build-release.sh          # результат: release/<метка времени>/
```

Шаги внутри скрипта, каждый останавливает сборку при отказе:

| № | Шаг | Чем выполняется |
|---|---|---|
| 1 | рабочий каталог чист | `git status --porcelain` |
| 2 | поиск секретов в файлах и в истории | **gitleaks** (`gitleaks dir`, `gitleaks git`) |
| 3 | сборка образов, загрузка базовых по digest | `docker compose build`, `docker pull` |
| 4 | проверка образов на известные уязвимости | **docker scout cves** либо **trivy** |
| 5 | каталог выпуска с меткой времени UTC | — |
| 6 | файлы времени выполнения | `tar` |
| 7 | состав поставки (SBOM) в формате SPDX | **docker scout sbom** либо **syft** |
| 8 | архив образов | `docker save \| gzip` |
| 9 | манифест: коммит, версии подмодулей, идентификаторы образов | `git`, `docker image inspect` |
| 10 | контрольные суммы и их немедленная проверка | `sha256sum` |

Два прогона gitleaks отвечают на разные вопросы: `dir` — что лежит в файлах сейчас, `git` — что
когда-либо было закоммичено. Секрет, удалённый последним коммитом, остаётся в истории и утечёт
вместе с репозиторием. У подмодуля своя история — его корень перечисляется в параметре
`SECRET_SCAN_PATHS` отдельной строкой.

Порог остановки на шаге 4 — критические и высокие. Разобранные находки, о которых известно, что к
этой сборке они не применимы, объявляются явно: для docker scout — документом OpenVEX
(`--vex-location`), для trivy — файлом `.trivyignore`. Подавление без разбора превращает шаг
проверки в формальность, поэтому список пустой, пока таких находок нет.

Состав каталога выпуска:

```
release/20260731T163002Z/
├── images.tar.gz         образы одним архивом
├── runtime.tar.gz        compose.prod.yml, edge/conf.d/gateway.conf, tools/deploy.sh
├── sbom/*.spdx.json      пакеты и версии внутри каждого образа
├── MANIFEST              коммит, версии подмодулей, идентификаторы образов
└── SHA256SUMS            контрольные суммы всего перечисленного
```

Артефакт неизменяем: та же метка времени всегда означает тот же набор образов. Файлы времени
выполнения едут вместе с образами, потому что без них выкат не воспроизводится — compose и
конфигурация входа задают, как именно образы запускаются. Изменяемые данные установки (`.env`,
каталог секретов, файл активного цвета, состояние) в артефакт не входят: он их не перезаписывает.

**Тег образа постоянный** (`example-app:prod`), версию несёт манифест. Контейнер держит тот
идентификатор образа, с которым был создан, поэтому загрузка нового образа под тем же тегом не
трогает уже запущенную копию. Это и делает откат мгновенным — и это же причина, по которой при
выкате нельзя запускать `docker compose up -d` без имён служб: такая команда пересоздаст и активную
копию, откатываться станет некуда.

**Секреты не входят в релиз.** Пароли и ключи живут на сервере (`.env`, файлы окружения) и
подставляются при запуске. Поэтому сборка на машине разработчика не требует боевых значений: для
подстановки в конфигурацию достаточно заглушек, если эти переменные нужны только во время работы, а
не во время сборки.

**Проверка:**

```bash
cd release/<метка> && sha256sum -c SHA256SUMS && cat MANIFEST
```

**Ожидается:** по строке `OK` на каждый файл; в манифесте — коммит и идентификатор каждого образа.
Любая строка `FAILED` означает, что артефакт собран не полностью и выкатывать его нельзя.

---

## Перенос на сервер

Два способа, различаются только тем, откуда сервер берёт образы.

**Через реестр** — если сервер в него ходит: `docker push` на машине сборки, `docker compose pull`
на сервере. Требуется реестр и учётные данные к нему на сервере.

**Архивом** — если сервер в реестр не ходит: своего реестра нет либо сервер намеренно не выпущен в
интернет. Тогда единица переноса — каталог выпуска целиком, вместе с `SHA256SUMS`:

```bash
# с машины сборки
ssh admin@example.com 'mkdir -p /srv/app/release'
./tools/deploy.sh push release/<метка> admin@example.com:/srv/app
```

`push` перед отправкой сверяет контрольные суммы и копирует каталог через `rsync --partial`
(прерванная передача продолжается, а не начинается заново); если `rsync` не установлен — через
`scp`. Проверка сумм повторяется на сервере при выкате: она отвечает на вопрос, доехал ли артефакт
целиком, и это единственный способ отличить повреждённую передачу от повреждённой сборки.

**Проверка:**

```bash
ssh admin@example.com 'cd /srv/app/release/<метка> && sha256sum -c SHA256SUMS'
```

**Ожидается:** `OK` по каждому файлу.

---

## Выкат

```bash
# на сервере, из каталога установки
./tools/deploy.sh deploy release/<метка>
```

Что происходит по шагам:

| № | Шаг | Что при отказе |
|---|---|---|
| 1 | проверка контрольных сумм артефакта | выкат не начинается |
| 2 | загрузка образов (`docker load`) | выкат не начинается |
| 3 | установка файлов времени выполнения с сохранением прежних | прежние возвращаются, вход перечитывает конфигурацию |
| 4 | подъём **свободного** цвета новыми образами | см. шаг 7 |
| 5 | резервная копия базы | выкат останавливается до миграций |
| 6 | миграции — один разовый исполнитель | выкат останавливается до переключения |
| 7 | проверка готовности новой копии | трафик остаётся на прежнем цвете, конфигурация возвращается |
| 8 | переключение входа | то же |

Ключевой момент — шаг 7: переключение происходит **только** после успешной проверки готовности.
Если проверка не прошла, трафик остаётся на прежней копии, скрипт печатает последние строки журналов
поднятой копии, возвращает прежние файлы времени выполнения и завершается с ненулевым кодом. Выкат
считается несостоявшимся; простоя при этом нет.

Проверяется именно ручка **готовности**, а не «жив»: копия, которая запустилась, но ещё не может
отвечать, не должна получать трафик. Запрос выполняется из контейнера входа
(`docker compose exec edge wget …`), потому что у копий нет опубликованных портов — снаружи их не
видно вовсе.

Перед установкой новых файлов времени выполнения прежние сохраняются в `.runtime-history/<метка>`, а
путь к ним записывается в `edge/state/previous-runtime`. Отсюда работает откат инфраструктуры —
см. раздел «Откат».

### Как переключается вход

Переключение — это две операции: переписать `edge/conf.d/active.inc` и выполнить `nginx -s reload`.

Именно **перезагрузка** (reload), а не перезапуск (restart). По сигналу перезагрузки мастер-процесс
nginx перечитывает конфигурацию и запускает новых рабочих; прежние дорабатывают уже принятые запросы
и завершаются сами. Слушающий сокет при этом не закрывается ни на мгновение — открытые соединения не
рвутся, новые продолжают приниматься. Перезапуск контейнера закрыл бы сокет, и на это время клиенты
получали бы отказ в соединении: то есть ровно тот простой, ради отсутствия которого держатся две
копии.

Порядок внутри `switch_color`:

1. текущий `active.inc` копируется во временный файл;
2. пишется новый — с меткой цвета в первой строке и строками `set` по числу переменных;
3. `nginx -t` проверяет конфигурацию; при ошибке прежний файл возвращается, трафик не тронут;
4. `nginx -s reload`; при ошибке прежний файл возвращается и перезагрузка повторяется на нём.

Каталог `edge/conf.d` подключён в контейнер входа как том, поэтому файл, заменённый на сервере,
виден внутри сразу — отдельного копирования в контейнер не требуется.

**Проверка после выката:**

```bash
./tools/deploy.sh status
curl -sf https://example.com/readyz
docker compose -f compose.prod.yml ps
```

**Ожидается:** `status` печатает цвет, на который переключились; ответ `/readyz` содержит версию из
нового выпуска; в `ps` запущены обе копии, прежняя — в состоянии `Up`, а не `Exited`.

---

## Миграции базы данных

Правила самих миграций (инструмент, именование файлов, таблица версий, блокировки) — в
[базе данных](./database.md). Здесь только то, что относится к выкату.

**Чем применяются.** Разовым контейнером из того же образа, что и приложение: служба `migrate` в
`compose.prod.yml` с профилем `tools`, запускается как `docker compose run --rm migrate`. Профиль
нужен, чтобы служба не поднималась вместе со стеком. Внутри контейнера работает мигратор из стека
сервиса — тот же, что применяет миграции в разработке.

**Когда применяются.** После подъёма свободной копии и до переключения входа. Один исполнитель на
выкат: если бы миграции применяла каждая копия при старте, две копии, поднятые одновременно, вошли
бы в них одновременно.

Из порядка следует, что новая копия какое-то время работает на старой схеме и может не выходить в
готовность. Это ожидаемо: при `restart: unless-stopped` она перезапускается, а проверка готовности
выполняется уже после миграций и ждёт до `READY_ATTEMPTS × READY_INTERVAL` секунд (по умолчанию две
минуты).

**Требование к содержанию.** База общая для обеих копий, поэтому во время выката с ней одновременно
работают старая и новая версии: **каждая миграция должна быть совместима с предыдущей версией
кода**.

Безопасные изменения: добавление таблицы, добавление необязательного поля, добавление индекса.

Опасные изменения выполняются в два выката:

| Задача | Выкат 1 | Выкат 2 |
|---|---|---|
| переименовать поле | добавить новое, писать в оба, читать из старого | читать из нового, удалить старое |
| удалить поле | перестать использовать в коде | удалить из схемы |
| сузить тип или добавить `NOT NULL` | заполнить значения, добавить проверку | ужесточить ограничение |

Правило: сначала выкатывается код, умеющий работать и со старой, и с новой схемой; изменение схемы,
ломающее старый код, идёт только после того, как старый код перестал работать.

---

## Откат

```bash
./tools/deploy.sh rollback
```

Команда делает две вещи.

**Откат трафика.** Проверяет, что прежняя копия готова, и возвращает на неё указатель входа. Она всё
это время работала, поэтому откат занимает время перезагрузки конфигурации входа, а не время сборки.

**Откат инфраструктуры.** Возвращает файлы времени выполнения того выката, при котором эта копия
работала: `compose.prod.yml`, конфигурацию входа, сам скрипт выката — из `.runtime-history/<метка>`
по указателю `edge/state/previous-runtime`. Без этого шага трафик вернулся бы на прежнюю копию, но
правила её запуска остались бы новыми, и первый же перезапуск контейнера поднял бы её по ним.
После восстановления вход перечитывает конфигурацию.

Чего откат **не** делает: не отменяет применённые миграции и не удаляет загруженные образы. Схема
возвращается отдельно — исправлением вперёд или восстановлением из копии
([база данных](./database.md), [резервное копирование](./backup-and-restore.md)).

Откат возможен, пока прежняя копия не заменена следующим выкатом. Отсюда правило: **не выкатывать
следующую версию, пока предыдущая не подтверждена как рабочая** — иначе откатываться станет некуда.

### Снятие

Вернуть сервер в состояние «одна копия» или убрать контур целиком:

```bash
# остановить свободный цвет, оставив активный работать
docker compose -f compose.prod.yml stop app-green app-gateway-green web-green

# убрать контур целиком, сохранив данные
docker compose -f compose.prod.yml down          # без --volumes

# освободить место от старых образов, сохранив образы предыдущего выпуска
docker image prune --filter 'until=168h'
```

Что при этом не удаляется: тома с данными (`down` без `--volumes` их не трогает), внешняя база,
резервные копии, каталоги выпусков в `release/` и сохранённые конфигурации в `.runtime-history/`.
Их удаляют вручную и по одному, потому что каждый из них — единственный путь назад для своего вида
отказа.

---

## Что должно быть в приложении

**Служебные ручки.** Минимум две, и они отвечают разное:

- `/livez` — процесс запущен и отвечает; используется для перезапуска зависшего контейнера;
- `/readyz` — зависимости доступны (база, очередь), можно давать трафик; используется при выкате.

Разделение важно: приложение может быть живым, но ещё не готовым — например, пока не применились
миграции. Если проверять только «жив», трафик переключится на копию, которая ещё не может отвечать.

**Корректное завершение.** По сигналу остановки сервис перестаёт принимать новые запросы, дорабатывает
текущие и завершается. Иначе при переключении часть запросов оборвётся.

**Версия в ответе служебной ручки** — по ней видно, какая копия сейчас принимает трафик.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| `есть незафиксированные изменения` | сборка запущена из грязного рабочего каталога | зафиксировать или убрать правки; релиз собирается только из зафиксированного состояния |
| gitleaks нашёл секрет в истории | значение когда-то было закоммичено | отозвать значение, заменить новым; чистка истории сама по себе не отменяет утечку |
| сборка падает на подстановке переменных | требуются переменные времени выполнения | передать заглушки: в образ они не попадают |
| `неполный каталог выпуска удалён` | шаг сборки отказал | смотреть сообщение выше: оно называет шаг и код возврата |
| `контрольные суммы не сошлись` | передача оборвалась либо каталог скопирован во время сборки | перенести заново; сверять суммы до и после переноса |
| `в архиве времени выполнения посторонний путь` | архив собран не этим скриптом либо изменён | пересобрать выпуск; выкат принимает только объявленные пути |
| `в active.inc строка не соответствует объявленному цвету` | файл правили вручную наполовину | привести файл к одному цвету, затем `deploy.sh status` |
| новая копия не выходит в готовность | приложение не поднялось, миграции не применились, недоступна зависимость | смотреть напечатанные журналы копии; трафик остался на прежней — простоя нет |
| `nginx: [emerg] host not found in upstream` | в `proxy_pass` имя без переменной, контейнер этого цвета отсутствует | вынести имя в переменную и добавить `resolver 127.0.0.11` |
| после выката часть запросов с ошибками | нет корректного завершения у прежней копии | реализовать обработку сигнала остановки |
| откат не помогает | несовместимая миграция уже изменила схему | восстанавливать из резервной копии; на будущее — двухшаговые миграции |
| откатываться некуда: обе копии на новой версии | при выкате выполнен `up -d` без имён служб либо два выката подряд | поднять прежний образ по идентификатору из манифеста предыдущего выпуска |
| места на диске не хватает после нескольких релизов | копятся старые образы и каталоги выпусков | `docker image prune` с ограничением по возрасту, оставляя предыдущий выпуск |

---

## Резервные копии

Выкат без резервной копии — операция без права на ошибку. Минимум: снимок базы перед применением
миграций и проверка, что снимок **восстанавливается**. Непроверенная резервная копия равнозначна её
отсутствию.

Снимок снимается шагом 5 выката — командой из параметра `BACKUP_CMD` в `tools/deploy.sh`. Если
параметр пуст, скрипт печатает предупреждение и продолжает: это осознанный выбор для сервиса без
своей базы, а не значение по умолчанию для боевого контура. Что именно запускать и как проверять
восстановление — в [резервном копировании](./backup-and-restore.md).

---

Документ: http://docs.gitaspen.ru/development/operations/secrets

# Секреты: хранение, доставка, ротация

Документ доводит систему до состояния «ни одно секретное значение не лежит в репозитории и в
образе; рядом с каждой частью системы лежит её файл значений; значение заменяется без простоя».
Исходное состояние: репозиторий с кодом, части системы описаны файлами compose, сервер с Docker.

Секрет — значение, знание которого даёт доступ: пароль базы, ключ подписи токенов, закрытый ключ,
ключ внешнего API, строка подключения с учётными данными. Адрес сервиса, имя базы и номер порта
секретом не являются; они попадают в тот же файл заодно, потому что описывают ту же установку.

**Что нужно до начала:**

```bash
git ls-files | grep -E '(^|/)\.env$|\.pem$'    # ожидается: пустой вывод
docker compose version                          # ожидается: версия Compose v2
```

Первая проверка обязательна: если файл со значениями уже отслеживается, значения уже в истории, и
начинать надо не с настройки, а с их замены — раздел «Утечка».

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| структура частей системы задаёт каталог `.secrets/` у каждой части; на сервере установлен Docker | что лежит в `.secrets/`, как значения попадают в контейнер и на сервер, как заменяются | сборка релиза и выкат: артефакт собирается без значений; резервное копирование, куда `.secrets/` входит |

Что предыдущее звено обязано обеспечить: `.gitignore` заведён **до первого коммита**. Файл, попавший
в историю, оттуда не исчезает при последующем добавлении в игнор — он остаётся во всех прошлых
состояниях и во всех клонах.

Что этот документ оставляет следующему: на сервере рядом с каждой частью лежит заполненный
`.secrets/.env`. Поэтому образ не содержит значений, и один и тот же артефакт разворачивается в
любой среде — это условие воспроизводимого релиза.

---

## Почему не в репозитории и не в образе

**Репозиторий клонируется.** У каждого, кто когда-либо его клонировал, есть полная история.
Удаление файла следующим коммитом не убирает значение ни из истории, ни из уже сделанных клонов и
форков.

**Образ раздаётся.** Слои читает любой, кто может его скачать; `docker history` показывает команды
сборки и аргументы (`ARG`); `COPY . .` без `.dockerignore` кладёт каталог со значениями прямо в
слой.

Отсюда рабочее правило: **значение, попавшее в историю репозитория или в опубликованный слой,
считается раскрытым.** Его меняют, а не прячут — скрыть уже розданное невозможно.

---

## Формат: каталог `.secrets/` рядом с частью системы

Каждая часть системы — бэкенд, фронтенд, шлюз — хранит значения сама, в каталоге `.secrets/` в
корне своей папки. Общего каталога на продукт нет: части разворачиваются по отдельности и на разные
машины, а общий файл пришлось бы копировать целиком туда, где нужна одна строка из него.

```
<часть>/
├── .secrets/
│   ├── .env               # значения этой части — в git не попадает
│   ├── .env.example       # шаблон с теми же ключами — попадает
│   └── signing_key.pem    # ключевой материал — не попадает
├── container              # образ
└── compose                # запуск
```

| Имя | Что внутри | В git |
|---|---|---|
| `.env` | строки `КЛЮЧ=значение`, по одной на строку | нет |
| `.env.example` | те же ключи с заглушками и комментариями | да |
| `*.pem`, `*.key`, `*.json` | ключевой материал, файлы учётных данных провайдеров | нет |

Если на одной машине разворачивается несколько сред одной части, добавляется уровень:
`.secrets/<среда>/.env`. Общего файла у сред нет — различие сред должно быть видно как разные файлы,
а не как разные строки в одном.

**Права и владелец:**

| Путь | Права | Владелец |
|---|---|---|
| `.secrets/` | `700` | учётная запись, от имени которой запускается `docker compose` |
| `.secrets/.env` | `600` | она же |
| `.secrets/*.pem` (закрытый ключ) | `600` | она же |
| `.secrets/.env.example` | `644` | она же; значений не содержит |

Владелец — именно эта учётная запись, а не `root`: файл окружения читается инструментом на хосте, и
файл, доступный только `root`, потребует выполнять весь выкат через `sudo`.

Права отделяют значения от прочих учётных записей на машине. От того, кто входит в группу `docker`,
они не защищают: через сокет демона монтируется любой каталог хоста (см. документ о Docker).

Схема рассчитана на установку, где серверы наперечёт и значения раздаются вручную. Централизованное
хранилище секретов решает ту же задачу иначе и само требует эксплуатации; здесь оно не
рассматривается.

---

## Шаг 1. Закрыть `.secrets/` от git и от сборки образа

В `.gitignore` в корне репозитория:

```gitignore
# Значения установки: содержимое .secrets/ в историю не попадает.
**/.secrets/*
# Шаблон — попадает: по нему видно, что нужно задать при развёртывании.
!**/.secrets/.env.example
```

Правило написано как «игнорировать всё, кроме шаблона», а не перечислением имён (`.env`, `*.pem`).
При перечислении файл с новым именем — выгруженный провайдером `credentials.json` — не попадает ни
под одно правило и добавляется в коммит незаметно.

В `.dockerignore` рядом с описанием образа:

```
**/.secrets
```

Без этой строки `COPY . .` копирует каталог со значениями в слой образа.

**Проверка:**

```bash
git check-ignore -v <часть>/.secrets/.env
# ожидается: строка вида «.gitignore:2:**/.secrets/*   <часть>/.secrets/.env»

git status --porcelain --ignored <часть>/.secrets/
# ожидается: .env помечен «!!» (игнорируется), .env.example — обычный файл

git ls-files -- '**/.secrets/'
# ожидается: только строки с .env.example
```

Пустой вывод первой команды означает, что правило на этот путь не действует, — а не что файл
безопасен.

---

## Шаг 2. Написать шаблон

Шаблон — рабочий файл, по которому установка поднимается с нуля: те же ключи, что в боевом файле,
заглушки вместо значений, комментарий о том, откуда значение взять и что будет, если оставить
пустым.

```dotenv
# Шаблон. Скопировать в .secrets/.env и заполнить.
# .env и *.pem в git не попадают (см. .gitignore).

SERVICE_NAME=example-service
DB_SCHEMA=example

# Строка подключения. Пользователь и база создаются при первом запуске compose.
DB_URL=postgresql://app:CHANGE_ME@db:5432/app

# Ключ подписи токенов. Сгенерировать: openssl rand -hex 32
JWT_SIGNING_KEY=CHANGE_ME
JWT_ALGORITHM=HS256
ACCESS_TOKEN_TTL_MINUTES=30

# Почта. Пустой SMTP_HOST — письма не отправляются, код остаётся в кеше.
SMTP_HOST=
SMTP_PORT=465
SMTP_PASSWORD=

# Внешний провайдер. Пустая пара ключей — ручка отвечает «не настроено».
PROVIDER_CLIENT_ID=
PROVIDER_CLIENT_SECRET=
```

Три требования к шаблону:

- **Заглушка выглядит как заглушка** (`CHANGE_ME`). Правдоподобное значение вида `change_me_in_prod`
  работает: система с ним запускается, ничего не ломается, и то, что ключ подписи известен всем,
  обнаруживается при разборе инцидента, а не при развёртывании.
- **Пустое значение и заглушка означают разное:** пустое — «возможность выключена», `CHANGE_ME` —
  «задать обязательно». Различие описывается комментарием, иначе по шаблону не понять, что из
  незаполненного обязательно.
- **Шаблон — не копия боевого файла.** Копия содержит значения; попав в git, она делает их
  раскрытыми.

**Проверка** — наборы ключей в файле и шаблоне совпадают:

```bash
diff <(grep -oE '^[A-Z0-9_]+' .secrets/.env         | sort -u) \
     <(grep -oE '^[A-Z0-9_]+' .secrets/.env.example | sort -u)
# ожидается: пустой вывод; сравниваются имена ключей, значения из файла не выходят
```

---

## Шаг 3. Заполнить значения и выставить права

Значения генерируются, а не придумываются: придуманное человеком значение короче и предсказуемее,
чем выглядит.

```bash
umask 077                                  # создаваемые файлы получат права 600
cp .secrets/.env.example .secrets/.env

openssl rand -hex 32                       # ключ подписи: 32 байта
openssl genrsa -out .secrets/signing_key.pem 2048          # если нужен алгоритм с парой ключей
openssl rsa -in .secrets/signing_key.pem -pubout \
            -out .secrets/signing_key.pub.pem

chmod 700 .secrets
chmod 600 .secrets/.env .secrets/signing_key.pem
```

**Проверка:**

```bash
stat -c '%a %U %n' .secrets .secrets/.env .secrets/signing_key.pem
# ожидается:
#   700 <учётная запись> .secrets
#   600 <учётная запись> .secrets/.env
#   600 <учётная запись> .secrets/signing_key.pem
```

---

## Шаг 4. Подключить значения к контейнеру

Способа два, и они не равнозначны.

| Способ | Как | Что даёт | Чего не даёт |
|---|---|---|---|
| окружение | `env_file: ./.secrets/.env` | работает с любым рантаймом, значение доступно процессу сразу | значение видно в `docker inspect` и в окружении процесса, наследуется дочерними процессами; меняется только пересозданием контейнера |
| файл | том `./.secrets:/app/.secrets:ro` | значения нет в конфигурации контейнера; файл заменяется и перечитывается без пересоздания; действуют права | приложение должно уметь читать значение из файла |

Правило: короткие значения настроек — окружением; ключевой материал (закрытые ключи, сертификаты,
файлы учётных данных провайдеров) — файлом. Файл выбирают и тогда, когда значение меняется чаще, чем
выкатывается версия.

```yaml
services:
  app:
    build: { context: ., dockerfile: container }
    env_file:
      - ./.secrets/.env               # значения окружения
    volumes:
      - ./.secrets:/app/.secrets:ro   # ключевой материал; том только на чтение
    expose: ["8000"]
```

Настройки приложения указывают тот же путь, что смонтирован (`env_file=".secrets/.env"` в описании
настроек), а не собирают значения из переменных по месту: тогда часть запускается и без контейнера,
при локальной отладке, тем же файлом.

**Почему значение не пишут в compose по месту.** Файл compose — часть кода: он лежит в репозитории и
попадает в историю. Кроме того, один и тот же файл описывает все среды, и вписанное значение
заставляет держать по копии файла на среду — копии расходятся. И третье: `docker compose config`
печатает описание с подставленными значениями, то есть обычная отладочная команда становится
способом их раскрыть.

**Две подстановки, которые путают.** `env_file` — это окружение **контейнера**; в тексте самого
compose эти значения не подставляются. Запись `${VAR}` в compose — подстановка на стороне
инструмента, значения берутся из окружения оболочки и из файла `.env` **рядом с файлом compose**.
Второй механизм пригоден для параметров запуска (порт публикации, имя образа), но не для секретов:
подставленное значение видно в `docker compose config`.

**Проверка:**

```bash
docker compose up -d
docker compose ps                         # ожидается: состояние healthy
curl -sf http://127.0.0.1:8000/health     # ожидается: ответ службы готовности
```

Ручка готовности подтверждает не доставку файла, а пригодность значений: сервис не станет готовым,
не подключившись к базе.

---

## Шаг 5. Доставить значения на сервер

Файл переносится по тому же защищённому каналу, по которому идёт управление сервером:

```bash
ssh example.com 'install -d -m 700 /opt/app/<часть>/.secrets'
ssh example.com 'umask 077 && cat > /opt/app/<часть>/.secrets/.env' < .secrets/.env
```

`umask 077` в удалённой команде создаёт файл сразу с правами `600`: он не существует ни секунды в
состоянии, доступном на чтение другим учётным записям. Подключение выполняется под той учётной
записью, от имени которой запускается `docker compose`, — иначе файл придётся передавать другому
владельцу отдельной командой.

Чего не делают: не пересылают значения сообщением, письмом или в тикет. Оттуда значение не
удаляется — оно остаётся в переписке, в её резервных копиях и у каждого участника канала.
Отправленное так значение считается раскрытым.

**Проверка:**

```bash
ssh example.com 'stat -c "%a %U %n" /opt/app/<часть>/.secrets /opt/app/<часть>/.secrets/.env'
# ожидается: 700 и 600, владелец — учётная запись выката

ssh example.com "grep -coE '^[A-Z0-9_]+' /opt/app/<часть>/.secrets/.env"
# ожидается: число ключей, равное числу ключей в шаблоне
```

---

## Шаг 6. Убедиться, что значение не вытекает

Три места, куда секрет попадает без всякого злого умысла: конфигурация контейнера, журнал, ответ
API.

```bash
# значение читается из файла, а не набирается: набранное осталось бы в истории оболочки
val=$(grep -m1 '^JWT_SIGNING_KEY=' .secrets/.env | cut -d= -f2-)

# 1. конфигурация контейнера
docker inspect "$(docker compose ps -q app)" --format '{{json .Config.Env}}' | grep -cF "$val"

# 2. журнал
docker compose logs app 2>&1 | grep -cF "$val"

# 3. ответы API, включая ответы об ошибке
curl -s http://127.0.0.1:8000/health | grep -cF "$val"
```

**Ожидается: `0` во всех трёх.**

`env_file` от первой проверки сам по себе не спасает: инструмент читает файл и кладёт значения в
конфигурацию контейнера, поэтому всё переданное окружением видно в `docker inspect` любому, у кого
есть доступ к демону. Файл окружения решает другую задачу — не пускает значения в репозиторий и в
командную строку. Ключевой материал поэтому передают файлом (шаг 4).

Что обеспечивает нули во второй и третьей проверке:

- в журнал не пишутся ни настройки целиком при старте, ни тело запроса целиком; идентификатор ключа
  — можно, значение — нет (см. документ о наблюдении);
- непредвиденная ошибка отдаётся наружу обезличенным кодом, а не текстом исключения: строка
  подключения обычно утекает именно так;
- служебной ручки, возвращающей настройки, в боевой сборке нет, либо она отдаёт имена ключей без
  значений.

**Образ:**

```bash
docker run --rm --entrypoint sh <образ> -c 'ls -a /app/.secrets 2>/dev/null || echo "каталога нет"'
# ожидается: «каталога нет»

docker history --no-trunc <образ> | grep -cF "$val"    # ожидается: 0
```

Аргументы сборки (`ARG`) остаются в образе и видны в `docker history` — секрет через них не
передают.

**Фронтенд:** значение, попавшее в клиентский бандл, перестаёт быть секретом — код фронта доступен
пользователю целиком. Проверка та же: поиск значения по собранным файлам.

---

## Ротация: плановая замена

Правило: в момент перехода у секрета **два действующих значения**. Если действующее значение одно,
момент замены совпадает с моментом отказа для всех, кто ещё пользуется прежним, — а таких всегда
больше нуля: выданные токены живут дольше выката, вторая копия приложения ещё работает на прежней
версии.

**Значение, которое проверяет наша же система** (ключ подписи токенов):

1. Научить проверяющую сторону принимать несколько значений: список ключей вместо одного, у каждого
   свой идентификатор. Выкатить.
   *Проверка:* ранее выданные токены по-прежнему принимаются.
2. Добавить новый ключ в список и переключить на него **выпуск**. Выкатить.
   *Проверка:* выданный сейчас токен содержит новый идентификатор ключа; выданный до этого
   принимается.
3. Выждать срок жизни самого долгоживущего значения, подписанного прежним ключом (обычно это срок
   токена обновления).
4. Убрать прежний ключ из списка. Выкатить.
   *Проверка:* токен, подписанный прежним ключом, отвергается.

**Значение, которое проверяет чужая сторона** (пароль базы, ключ внешнего API):

1. Завести второе действующее значение **на проверяющей стороне**: второй ключ в кабинете
   провайдера; вторая учётная запись базы с теми же правами. Прежнее продолжает работать.
2. Записать новое значение в `.secrets/.env` на сервере (шаг 5).
3. Поднять неактивную копию приложения — она стартует уже с новым значением. Проверить служебные
   ручки.
4. Переключить вход на неё (см. документ о релизе и выкате).
5. Отозвать прежнее значение на проверяющей стороне **после** того, как прежняя копия остановлена.

Порядок шагов 4 и 5 обязателен: пока прежнее значение действует, откат — это возврат указателя на
прежнюю копию. Отзыв до подтверждения новой версии оставляет систему без пути назад.

Если проверяющая сторона второго значения не поддерживает (некоторые базы хранят один пароль на
учётную запись), вторым действующим значением становится вторая учётная запись — заводится, права
выдаются те же, прежняя удаляется на шаге 5.

---

## Утечка

Признак утечки: значение оказалось в коммите, в журнале, в сообщении, в тикете, в снимке экрана, у
человека, которому оно больше не положено. Порядок действий один и тот же, приоритет — отзыв.

1. **Отозвать значение на проверяющей стороне.** Не «сменить в файле», а сделать прежнее
   недействующим. До отзыва замена ничего не даёт: у того, кто получил значение, оно продолжает
   работать.
2. Выпустить новое, доставить, перезапустить — по порядку из раздела о ротации, но без выдержки:
   держать два действующих значения при утечке незачем.
3. **Проверить историю репозитория.** Если значение когда-либо было закоммичено, оно есть во всех
   клонах; удаление файла новым коммитом не помогает.

   ```bash
   git log --all --oneline -S'<фрагмент значения>'   # ожидается: пустой вывод
   git log --all --oneline --name-only --pretty=format: -- '**/.secrets/*' | sort -u
   # ожидается: только .env.example
   ```

   Непустой вывод означает, что чистка истории обязательна, а всех владельцев клонов надо
   предупредить. Отзыв при этом всё равно первичен: чистка не достаёт значение из чужих копий.
4. **Проверить остальные места:** журналы (в том числе собранные в хранилище), резервные копии,
   переписку, тикеты. Каждое такое место — самостоятельный источник, и каждое переживает замену
   файла на сервере.
5. **Посмотреть, что успели сделать** с раскрытым значением: журнал доступа на проверяющей стороне
   за период от вероятного момента утечки до отзыва.

---

## Откат и снятие

**Новое значение не подошло.** Пока прежнее не отозвано, откат — это возврат прежнего значения:

```bash
ssh example.com 'umask 077 && cat > /opt/app/<часть>/.secrets/.env' < .secrets/.env.prev
ssh example.com 'cd /opt/app/<часть> && docker compose up -d --force-recreate app'
curl -sfI https://example.com/health     # ожидается: 200
```

Прежняя копия файла хранится до подтверждения новой — рядом, с теми же правами (`600`), и удаляется,
когда новое значение отработало. Если замена выполнялась через переключение копий приложения, откат
сводится к возврату указателя: прежняя копия работает с прежним значением, которое ещё действует.

**Если прежнее значение уже отозвано,** отката нет: назад пути не существует, потому что старое
значение больше не принимается. Выход — выпустить ещё одно новое и пройти доставку заново. Отсюда и
правило «отзывать последним».

**Снятие секрета совсем** (возможность отключается, часть выводится из эксплуатации):

1. отозвать значение на проверяющей стороне;
2. удалить файл и пересоздать контейнер:
   `rm .secrets/.env && docker compose up -d --force-recreate app`;
3. убрать ключ из `.env.example`, чтобы новая установка его не требовала.

Что при этом **не удаляется само**: значение остаётся в резервных копиях — там лежит архив
`.secrets/`, и он живёт до конца срока хранения. Поэтому снятие всегда начинается с отзыва: пока
значение действует, его копия в архиве — тоже действующий доступ.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| `.env` виден в `git status` как новый файл | правило не покрывает путь: игнорируется `.env` в корне, а файл лежит в `.secrets/` | правило `**/.secrets/*`; проверить `git check-ignore -v` |
| шаблон `.env.example` исчез из git вместе с `.env` | правило вида `.env*` захватило и шаблон | игнорировать содержимое каталога и вернуть шаблон строкой `!**/.secrets/.env.example` |
| файл в игноре, но продолжает отслеживаться | попал в историю раньше игнора | значение считать раскрытым: отозвать и заменить, затем убрать файл из индекса |
| после правки `.env` контейнер работает со старым значением | окружение фиксируется при создании контейнера; `docker compose restart` перезапускает существующий | `docker compose up -d --force-recreate <сервис>` |
| приложение не видит переменную, файл на месте | путь в `env_file` разрешается относительно файла compose, а не текущего каталога | указать путь от файла compose; проверить `docker inspect` |
| `Permission denied` на файле ключа внутри контейнера | файл `600` принадлежит одному пользователю, процесс в контейнере работает под другим | задать контейнеру того же владельца (`user:`) либо выдать доступ группе (`640` и общая группа) |
| один образ, два сервера — разное поведение | значения в `.env` разошлись | сверять **наборы ключей** с шаблоном (`grep -oE '^[A-Z0-9_]+'`), расхождение по именам ищется без раскрытия значений |
| в журнале обнаружилось значение | логируются настройки при старте или тело запроса целиком | убрать поля из логирования, значение отозвать и заменить |
| значение видно в `docker inspect` | оно передано окружением | для ключевого материала перейти на файл и том только на чтение |
| значение оказалось в опубликованном образе | нет `.dockerignore`, `COPY . .` забрал `.secrets/` | добавить `**/.secrets`, пересобрать, значение отозвать и заменить |
| после замены часть пользователей получает отказ | заменили одним действием, без переходного периода | вернуть прежнее значение и пройти ротацию с двумя действующими |
| замену откатить не удаётся | прежнее значение отозвали до подтверждения нового | выпустить новое; на будущее — отзыв последним шагом |
| права `644` на `.env` | файл создан редактором или скопирован без `umask` | `chmod 600`; при доставке использовать `umask 077` |

---

Документ: http://docs.gitaspen.ru/development/operations/network-topology

# Сетевой контур: от домена до микросервиса

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

Документ отвечает на вопросы: что публикуется наружу, что не публикуется никогда, какой nginx за
что отвечает и где искать причину, когда «сервис не отвечает».

---

## Цепочка

```
браузер
  │  https://example.com
  ▼
nginx хоста                  сервер шлюза, порт 443
  │  TLS завершается здесь; дальше — обычный HTTP внутри машины
  │  proxy_pass → 127.0.0.1:8000
  ▼
шлюз приложения (nginx)      контейнер, порт 80, опубликован на 127.0.0.1:8000
  │  маршруты, CORS, служебные ручки
  │  proxy_pass → upstream по адресу частной сети
  ▼
частная сеть (VPN)           отдельный сервер микросервиса
  ▼
nginx микросервиса           слушает адрес частной сети
  │  proxy_pass → backend:8000
  ▼
backend                      expose 8000; на хосте не публикуется
```

Каждый слой решает одну задачу и не знает о задачах соседей.

| Слой | Отвечает за | Не отвечает за |
|---|---|---|
| nginx хоста | домен, сертификат, HTTP→HTTPS | маршруты приложения |
| шлюз приложения | маршруты на микросервисы, CORS, health | TLS, бизнес-логика |
| nginx микросервиса | вход в микросервис со стороны частной сети | маршрутизация чужих сервисов |
| backend | предметная логика | сеть и публикация |

Разделение позволяет менять слои по отдельности: добавление микросервиса меняет конфигурацию
шлюза, но не трогает домен и сертификат; смена домена не задевает приложение.

---

## Что публикуется наружу

Единственная точка входа снаружи — порты 80 и 443 на сервере шлюза. Всё остальное недоступно из
интернета.

| Компонент | Публикация | Почему так |
|---|---|---|
| nginx хоста | `80`, `443` на всех интерфейсах | это и есть вход |
| шлюз приложения | `127.0.0.1:8000:80` | доступен только nginx хоста |
| nginx микросервиса | без публикации, адрес в частной сети | доступен только шлюзу |
| backend | `expose: 8000` | доступен только своему nginx |
| база данных, очередь | без публикации | доступны только своим сервисам |

Проверка со стороны (с другой машины) — открыт должен быть только 443 (и 80 под редирект):

```bash
curl -m 5 -I https://example.com          # ожидается ответ приложения
curl -m 5 -I http://example.com:8000      # ожидается таймаут или отказ соединения
```

Ответ на втором запросе означает, что шлюз опубликован на всех интерфейсах и доступен в обход TLS.

---

## Частная сеть между серверами

Микросервисы на разных серверах соединяются частной сетью, а не через публичные адреса. Так адреса
микросервисов не существуют в интернете, и правило «наружу торчит только 443» продолжает
выполняться при любом числе серверов.

В Compose это выглядит как отдельный сервис-клиент частной сети, к сетевому пространству которого
подключается nginx микросервиса:

```yaml
networks:
  host_net: { driver: bridge }
  vpn_net:  { driver: bridge }

services:
  vpn_client:                     # клиент частной сети
    build: { context: ., dockerfile: vpn/Dockerfile }
    networks: [vpn_net]
    privileged: true
    devices: ["/dev/net/tun:/dev/net/tun"]

  service_nginx:                  # вход в микросервис со стороны частной сети
    build: { context: ., dockerfile: nginx/Dockerfile }
    network_mode: "service:vpn_client"    # общее сетевое пространство с клиентом
    depends_on: [backend]
    environment:
      - BACKEND_HOST=10.0.0.100
      - BACKEND_PORT=8000

  backend:
    build: { context: ., dockerfile: source/Dockerfile }
    networks: [host_net, vpn_net]
    expose: ["8000"]              # порт объявлен, но не опубликован
    depends_on: [vpn_client]
```

`network_mode: "service:vpn_client"` помещает nginx в сетевое пространство клиента частной сети:
контейнер получает её интерфейс и адрес. Поэтому nginx виден соседним серверам по адресу частной
сети, при этом на хосте не публикуется ни один порт.

### На каждый вход — свой nginx

Правило: **сколько у сервиса входов, столько перед ним и nginx**. Вход — это сеть, из которой к
сервису приходят: частная сеть между серверами и локальный хост — разные входы, и у каждого свой
nginx со своей привязкой.

| Вход | Свой nginx | Как объявлен |
|---|---|---|
| частная сеть (шлюз на другом сервере) | `vpn_nginx` | `network_mode: "service:vpn_client"`, портов на хосте нет |
| локальный хост (шлюз на этом же сервере) | `host_nginx` | `ports: ["127.0.0.1:8000:8000"]` |

Нужен один вход — работает один nginx; нужны оба — работают оба, каждый на своей сети. Сам сервис
при этом не меняется: он не знает, откуда пришёл запрос, и его настройка не зависит от размещения.

**Зачем так.** Цель — чтобы сам сервис никогда не смотрел наружу. Он слушает только внутри
контейнерной сети (`expose`), и любое обращение к нему проходит через nginx. Отсюда следует:

- **нет прямого доступа.** Сервис нельзя дёрнуть в обход — снаружи нет ни порта, ни адреса, по
  которому он отвечает;
- **нельзя обойти проверки.** Авторизация, ограничение частоты запросов, CORS, предельный размер
  тела живут на входе. Опубликованный порт сервиса означал бы путь мимо всего этого;
- **внутреннее не утекает.** Схема API, отладочные и служебные ручки, метрики видны только тем
  входам, которым их открыли, а не всему интернету;
- **вход можно закрыть, не трогая сервис.** Отключение или ограничение доступа — это правка nginx,
  а не перезапуск и переконфигурация приложения.

Из этого же следует, почему запрещена публикация порта самого сервиса: она создаёт вход, у которого
нет своего nginx, — то есть путь внутрь без единой проверки.

```yaml
  # вход со стороны частной сети — когда шлюз на другом сервере
  vpn_nginx:
    build: { context: ., dockerfile: nginx/Dockerfile }
    network_mode: "service:vpn_client"
    environment: [BACKEND_HOST=10.0.0.100, BACKEND_PORT=8000]
    depends_on: [backend]

  # вход с этого же хоста — когда шлюз рядом; включается вместо предыдущего
  # host_nginx:
  #   build: { context: ., dockerfile: nginx/Dockerfile }
  #   environment: [BACKEND_HOST=10.0.1.100, BACKEND_PORT=8000]
  #   networks: [host_net]
  #   ports: ["127.0.0.1:8000:8000"]
  #   depends_on: [backend]
```

Неиспользуемый вход остаётся закомментированным рядом: при переносе сервиса на другой сервер
переключение сводится к смене входа, а не к переписыванию описания.

Что не меняется ни в одном случае — сам `backend`: у него `expose`, и он недоступен ни с хоста, ни
из сети. Оба входа проксируют к нему изнутри. Публикация порта самого `backend` сделала бы обходной
путь мимо nginx микросервиса.

Шлюз обращается к микросервисам по этим адресам, а сами адреса задаются переменными окружения:

```nginx
upstream core_service {
    server ${CORE_SERVICE_ADDRESS} max_fails=3 fail_timeout=30s;
    keepalive 32;
}
```

Хранение адресов в переменных, а не в конфигурации, позволяет переносить микросервис на другой
сервер, меняя только окружение шлюза.

---

## Как проверять по слоям

Когда «сайт не работает», проверка идёт снизу вверх — от кода к домену. Первый слой, который не
отвечает, и есть место отказа.

```bash
# 1. backend внутри своего compose
docker compose exec service_nginx curl -sf http://10.0.0.100:8000/health && echo OK

# 2. микросервис со стороны частной сети (с сервера шлюза)
curl -sf http://10.0.0.10:8000/health && echo OK

# 3. шлюз на своём сервере
curl -sf http://127.0.0.1:8000/health && echo OK

# 4. весь контур снаружи
curl -sfI https://example.com/health && echo OK
```

| Первый неотвечающий слой | Где искать |
|---|---|
| 1 | приложение не запустилось: `docker compose logs backend` |
| 2 | частная сеть: клиент не поднял туннель, нет маршрута, адрес занят другим сервером |
| 3 | шлюз: неверный `upstream`, микросервис не в списке, ошибка конфигурации |
| 4 | nginx хоста, DNS или сертификат — см. документ о HTTPS |

Служебная ручка `/health` должна быть на каждом слое: она отвечает без обращения к базе и внешним
системам, поэтому по ней проверяется именно связность, а не работоспособность всей системы.

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| `502` от домена | шлюз не отвечает на `127.0.0.1:8000` | проверить, что контейнер шлюза запущен и порт опубликован на loopback |
| `502` от шлюза | микросервис недоступен по адресу частной сети | проверить туннель и адрес в переменных окружения шлюза |
| `504` | микросервис отвечает дольше таймаута | смотреть логи микросервиса; поднимать таймаут только после выяснения причины |
| сервис доступен по `http://example.com:8000` | шлюз опубликован без адреса | заменить публикацию на `127.0.0.1:8000:80` |
| после переноса микросервиса всё сломалось | адрес зашит в конфигурацию шлюза | вынести адрес в переменную окружения |
| CORS-ошибка в браузере при работающем API | заголовки выставляют и шлюз, и приложение | оставить CORS только на шлюзе, из приложения убрать |
| часть маршрутов ведёт не туда | пересекающиеся `location` в конфигурации шлюза | проверить порядок и точность совпадений |

---

## Место в цепочке документов

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| установленный Docker | как соединены слои и что публикуется | настройка домена и TLS на входе, затем выкат новых версий |

---

Документ: http://docs.gitaspen.ru/development/operations/private-network

# Частная сеть между серверами

Инструкция соединяет несколько серверов частной сетью на WireGuard: у каждой машины появляется
адрес, по которому её видят остальные машины контура и не видит интернет. После выполнения сервисы
обращаются друг к другу по этим адресам, а наружу по-прежнему смотрят только порты 80 и 443 на
сервере шлюза.

Исходное состояние: две или более машины, у каждой есть публичный адрес и вход по SSH, между собой
они связаны только через интернет. Конечное состояние: интерфейс `wg0` на каждой машине, адреса из
одного плана, рукопожатие между пирами, и контейнер-клиент рядом с сервисом, к сетевому
пространству которого подключается его nginx.

Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом. Результат другой —
переходите к разделу «Типичные отказы», не выполняя следующий шаг.

**Что нужно до начала:**

- две машины с Ubuntu 22.04/24.04 LTS или Debian 12, доведённые до состояния из
  [базовой настройки сервера](./server-setup.md): рабочий пользователь с `sudo`, включённый `ufw`,
  синхронизированное время;
- на машинах, где будут контейнеры, — [установленный Docker](./docker-install.md);
- у машины, которая станет узлом-концентратором, — постоянный публичный адрес или доменное имя,
  указывающее на неё;
- аварийная консоль провайдера. Ошибка в правилах пересылки доступ по SSH не отнимает, но
  восстанавливать связность быстрее из консоли.

Проверки предусловий — на каждой машине:

```bash
. /etc/os-release && echo "$ID $VERSION_ID"   # ожидается: ubuntu 22.04 / ubuntu 24.04 / debian 12
uname -r                                       # ожидается: 5.6 или новее
sudo modprobe wireguard && lsmod | grep -c '^wireguard'   # ожидается: 1
timedatectl show -p NTPSynchronized --value    # ожидается: yes
```

Начиная с версии 5.6 модуль `wireguard` входит в состав ядра, поэтому `modprobe` отрабатывает до
установки каких-либо пакетов. Синхронное время нужно самому протоколу: рукопожатие несёт метку
времени, и инициатор с отставшими часами отвергается стороной, которая уже видела более позднюю
метку.

Проверка, что выбранный план адресов ни с чем не пересекается, — на каждой машине:

```bash
ip -o -4 addr show | awk '{print $2, $4}'
docker network ls -q | xargs -r docker network inspect \
  -f '{{.Name}} {{range .IPAM.Config}}{{.Subnet}}{{end}}'
```

**Ожидается:** ни один адрес и ни одна подсеть не попадают в диапазон, отведённый под туннель.
Пересечение — самая частая причина того, что пир поднимается, а трафик до него не доходит: ядро
отправляет пакет на локальный мост, а не в туннель.

Если у провайдера включён облачный межсетевой экран, разрешите в нём `51820/udp` отдельно: правила
`ufw` на него не действуют.

## Место в цепочке

| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| [настроенный сервер](./server-setup.md) и [установленный Docker](./docker-install.md) на каждой машине | узел-концентратор, план адресов, пиры, клиент в контейнере рядом с сервисом | [сетевой контур](./network-topology.md): шлюз обращается к микросервисам по адресам этой сети |

Что предыдущие звенья обязаны обеспечить:

- включённый `ufw` с разрешённым SSH — иначе открытие одного UDP-порта не имеет смысла: открыто
  всё;
- синхронизированное время — см. выше;
- правило публикации портов из [документа о Docker](./docker-install.md). Частная сеть заменяет
  публикацию, а не дополняет её: если порт сервиса опубликован на всех интерфейсах, туннель ничего
  не закрывает.

Что этот документ оставляет следующему звену:

- адрес каждой машины в туннеле — именно он подставляется в `upstream` шлюза и в переменные
  окружения вида `CORE_SERVICE_ADDRESS`;
- контейнер `vpn_client` в Compose-файле микросервиса — тот самый, к которому
  [сетевой контур](./network-topology.md) подключает nginx через
  `network_mode: "service:vpn_client"`;
- непересекающийся план подсетей, на который опираются `ipam`-блоки Compose.

**Порядок принципиален** в трёх местах:

1. Ключи создаются до конфигурации: в `wg0.conf` подставляется значение закрытого ключа, а не
   ссылка на файл.
2. Блок `[Peer]` появляется на концентраторе **до** первого запуска клиента. Рукопожатие с
   неизвестным ключом отбрасывается молча — на клиенте это выглядит так же, как закрытый порт, и
   разбирать придётся две причины сразу.
3. UDP-порт открывается до запуска клиента — по той же причине.

---

## Зачем частная сеть

Сервис, доступный из интернета, доступен всем: любой запрос доходит до него напрямую, минуя шлюз с
его авторизацией, ограничением частоты запросов и предельным размером тела. Частная сеть убирает
такой путь. Микросервис слушает адрес, которого в интернете не существует: маршрута к нему нет ни
у кого, кроме машин с ключом от туннеля.

| Свойство | Обращение по публичному адресу | Обращение по адресу частной сети |
|---|---|---|
| кто может отправить пакет | любой хост в интернете | только пиры туннеля |
| что открыто на сервере микросервиса | порт сервиса | один UDP-порт на концентраторе |
| чем ограничен доступ | правилами приложения и экрана | наличием закрытого ключа |
| что видно при сканировании адреса | открытый порт | ничего |

Это не отменяет проверок на входе: nginx микросервиса и шлюз остаются на своих местах. Частная
сеть убирает обходной путь, а не заменяет то, что стоит на пути основном.

---

## Схема адресации

WireGuard адреса не раздаёт: DHCP в нём нет. Адрес пира записан в двух местах — в его собственном
`[Interface] Address` и на концентраторе в `AllowedIPs` его блока `[Peer]`. Расхождение между ними
означает, что пакеты пира отбрасываются как пришедшие с чужого адреса. Поэтому адрес назначается
один раз и фиксируется в журнале.

Журнал соответствия «адрес — машина» ведётся в одном месте: комментарием над каждым блоком `[Peer]`
в `wg0.conf` концентратора. Отдельный файл со списком расходится с реальностью на первой же правке.

Весь контур живёт внутри `10.0.0.0/8`, разделённого на непересекающиеся планы:

| План | Диапазон | Что адресует | Кто выдаёт |
|---|---|---|---|
| туннель | `10.0.0.0/24` | интерфейсы `wg0` всех машин | человек, вручную, с записью в `wg0.conf` концентратора |
| мост частной сети сервера N | `10.21.N.0/24` | контейнеры, которым нужен выход в туннель | Compose, блок `ipam` |
| мост хоста сервера N | `10.20.N.0/24` | контейнеры, доступные с этой же машины | Compose, блок `ipam` |

Пример на две машины:

| Роль | Адрес в туннеле | `vpn_net` | `host_net` |
|---|---|---|---|
| сервер шлюза, он же концентратор | `10.0.0.1` | `10.21.1.0/24` | `10.20.1.0/24` |
| сервер микросервиса | `10.0.0.10` | `10.21.2.0/24` | `10.20.2.0/24` |

Внутри моста последний октет закрепляется за ролью: `.10` — контейнер клиента туннеля, `.100` —
backend. Это соглашение, а не требование протокола; его смысл в том, что адрес в Compose-файле
читается без сверки с чужой конфигурацией.

Концентратором здесь работает сервер шлюза: у него уже есть публичный адрес и открытые порты, и
отдельная машина ради одного интерфейса не нужна. Когда шлюзов несколько, концентратор выносят на
отдельную машину — схема при этом не меняется, меняется только то, чей адрес стоит в `Endpoint`.

Адреса локальных мостов, которые встречаются в [сетевом контуре](./network-topology.md)
(`10.0.0.100` для `vpn_net` и `10.0.1.100` для `host_net`), — это адреса `backend` на двух мостах
одного сервера; конкретные значения задаёт блок `ipam` его Compose-файла. Жёсткое требование одно:
подсети мостов не пересекаются ни между собой, ни с планом туннеля.

Почему на концентраторе у пира всегда `/32`. `AllowedIPs` в WireGuard работает в обе стороны: для
исходящих пакетов это таблица маршрутов, для входящих — фильтр допустимых адресов отправителя. Пир
с `AllowedIPs = 10.0.0.0/24` получил бы право отправлять пакеты от имени любой машины контура.
Маска `/32` оставляет за пиром ровно один адрес.

---

## Шаг 1. Пакеты и ключи на концентраторе

```bash
sudo apt-get update
sudo apt-get install -y wireguard wireguard-tools

sudo install -d -m 700 /etc/wireguard
wg genkey | sudo tee /etc/wireguard/server_private.key >/dev/null
sudo chmod 600 /etc/wireguard/server_private.key
sudo sh -c 'wg pubkey < /etc/wireguard/server_private.key > /etc/wireguard/server_public.key'
sudo chmod 644 /etc/wireguard/server_public.key
```

**Проверка:**

```bash
sudo ls -l /etc/wireguard
```

**Ожидается:** `server_private.key` с правами `-rw-------`, `server_public.key` с правами
`-rw-r--r--`. Права на закрытый ключ шире `600` — исправьте до продолжения: файл читают все
пользователи машины.

Закрытый ключ не покидает машину, на которой создан. Наружу отдаётся только содержимое
`server_public.key`.

---

## Шаг 2. Конфигурация `wg0.conf` на концентраторе

```bash
sudo tee /etc/wireguard/wg0.conf >/dev/null <<'EOF'
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <содержимое /etc/wireguard/server_private.key>

PostUp   = iptables -I FORWARD 1 -i wg0 -o wg0 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -o wg0 -j ACCEPT
EOF

sudo chmod 600 /etc/wireguard/wg0.conf
```

Что означает каждая строка:

| Строка | Смысл |
|---|---|
| `Address = 10.0.0.1/24` | адрес концентратора в туннеле; маска `/24` задаёт, какой диапазон считается локальным для `wg0` |
| `ListenPort = 51820` | UDP-порт, который слушает интерфейс; фиксируется явно, потому что на него ссылается правило экрана и `Endpoint` клиентов |
| `PrivateKey` | значение ключа, а не путь к файлу |
| `PostUp` / `PostDown` | разрешение пересылать пакеты между пирами и снятие этого разрешения при остановке |

Правило пересылки ставится **первым** в цепочке (`-I FORWARD 1`). В `FORWARD` `ufw` держит
собственные переходы; правило, добавленное в конец (`-A`), срабатывает или нет в зависимости от их
содержимого, а поставленное первым — не зависит.

Пересылка разрешается только между пирами (`-i wg0 -o wg0`). Выхода из туннеля в интернет здесь
нет и NAT не настраивается: это частная сеть между серверами, а не выходной узел.

`SaveConfig` не включается. С ним `wg-quick down` перезаписывает файл своим представлением
состояния и удаляет комментарии — вместе с журналом соответствия адресов машинам.

**Проверка:**

```bash
sudo grep -c '^PrivateKey = <' /etc/wireguard/wg0.conf   # ожидается: 0
```

**Ожидается:** `0` — заполнитель заменён на значение ключа. Единица означает, что в файле остался
текст `<содержимое ...>`, и интерфейс не поднимется.

---

## Шаг 3. Пересылка пакетов

Без пересылки концентратор отвечает на обращения к своему адресу, но не передаёт пакеты между
двумя пирами: сервер шлюза увидит `10.0.0.1` и не увидит `10.0.0.10`.

```bash
sudo tee /etc/sysctl.d/99-wireguard.conf >/dev/null <<'EOF'
net.ipv4.ip_forward = 1
EOF

sudo sysctl --system
```

**Проверка:**

```bash
sysctl net.ipv4.ip_forward     # ожидается: net.ipv4.ip_forward = 1
```

Отдельный файл в `/etc/sysctl.d/` вместо правки `/etc/sysctl.conf` — чтобы настройку было видно
как принадлежащую этому документу и чтобы снятие сводилось к удалению одного файла.

---

## Шаг 4. Открыть один порт UDP

```bash
sudo ufw allow 51820/udp
sudo ufw status verbose
```

**Ожидается** строка `51820/udp ALLOW IN Anywhere` и отсутствие других новых разрешений. TCP на
этом порту не открывается: WireGuard работает только по UDP, разрешение TCP не даёт ничего, кроме
дополнительной открытой точки.

---

## Шаг 5. Запуск и автозапуск

```bash
sudo systemctl enable --now wg-quick@wg0
```

Команда `wg-quick up wg0` делает то же самое разово, но не переживает перезагрузку, и после неё
`systemctl start` завершается ошибкой `wg0 already exists`. Используйте один способ — через
systemd.

**Проверка:**

```bash
sudo wg show
```

**Ожидается:**

```
interface: wg0
  public key: <открытый ключ концентратора>
  private key: (hidden)
  listening port: 51820
```

Пиров пока нет — это состояние соответствует конфигурации.

```bash
sudo ss -ulpn | grep 51820
```

**Ожидается** строка вида `UNCONN 0 0 0.0.0.0:51820 0.0.0.0:*` **без имени процесса**: сокет держит
модуль ядра, а не пользовательская программа. Пустая колонка процесса здесь — норма, а не признак
неисправности.

```bash
ip addr show wg0        # ожидается: inet 10.0.0.1/24, состояние UP
```

---

## Шаг 6. Ключи и адрес для второго сервера

Выполняется **на сервере микросервиса** — закрытый ключ создаётся там, где будет использоваться, и
по сети не передаётся:

```bash
sudo apt-get update
sudo apt-get install -y wireguard wireguard-tools

sudo install -d -m 700 /etc/wireguard
wg genkey | sudo tee /etc/wireguard/private.key >/dev/null
sudo chmod 600 /etc/wireguard/private.key
sudo sh -c 'wg pubkey < /etc/wireguard/private.key > /etc/wireguard/public.key'
sudo cat /etc/wireguard/public.key
```

Содержимое `public.key` переносится на концентратор. Обратно с концентратора берётся содержимое
`server_public.key`.

Дальше — **на концентраторе**. Блок пира дописывается в конец `wg0.conf`:

```ini
# сервер микросервиса
[Peer]
PublicKey = <открытый ключ сервера микросервиса>
AllowedIPs = 10.0.0.10/32
```

Применение без разрыва уже установленных соединений:

```bash
sudo wg syncconf wg0 <(sudo wg-quick strip wg0)
```

`wg-quick strip` убирает из файла директивы, которые понимает только `wg-quick` (`Address`,
`PostUp`, `DNS`), и отдаёт остальное `wg syncconf`. Тот приводит состояние интерфейса к файлу,
не пересоздавая его. Пара `wg-quick down` / `wg-quick up` дала бы тот же результат, но оборвала бы
все остальные пиры.

**Проверка:**

```bash
sudo wg show wg0 allowed-ips
```

**Ожидается** строка с открытым ключом сервера микросервиса и `10.0.0.10/32`. Обращений от пира
ещё нет — рукопожатие появится после шага 7.

---

## Шаг 7. Клиент на сервере микросервиса

```bash
sudo tee /etc/wireguard/wg0.conf >/dev/null <<'EOF'
[Interface]
Address = 10.0.0.10/24
PrivateKey = <содержимое /etc/wireguard/private.key>

[Peer]
PublicKey = <содержимое server_public.key концентратора>
Endpoint = vpn.example.com:51820
AllowedIPs = 10.0.0.0/24
PersistentKeepalive = 25
EOF

sudo chmod 600 /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0
```

| Параметр | Значение | Почему так |
|---|---|---|
| `AllowedIPs = 10.0.0.0/24` | только подсеть туннеля | в туннель уходит трафик к машинам контура; весь остальной — обычным маршрутом |
| `AllowedIPs = 0.0.0.0/0` | весь трафик | превращает концентратор в единственный выход машины в интернет; для связи между серверами не требуется |
| `PersistentKeepalive = 25` | пакет каждые 25 секунд | удерживает запись в таблице NAT провайдера, иначе входящие пакеты перестают доходить через несколько минут тишины |
| `ListenPort` не задан | порт выбирается ядром | клиент не принимает входящие соединения, фиксировать порт незачем |

Значение `AllowedIPs` определяет и маршруты: `wg-quick` добавляет маршрут на каждую подсеть из
этого списка. Указание `0.0.0.0/0` увело бы в туннель в том числе SSH-сессию, по которой идёт
настройка.

**Проверка — на сервере микросервиса:**

```bash
sudo wg show
```

**Ожидается:**

```
interface: wg0
  public key: <открытый ключ этого сервера>
  private key: (hidden)
  listening port: <выбран ядром>

peer: <открытый ключ концентратора>
  endpoint: <публичный адрес концентратора>:51820
  allowed ips: 10.0.0.0/24
  latest handshake: 12 seconds ago
  transfer: 1.98 KiB received, 3.12 KiB sent
```

Ключевая строка — `latest handshake`. Её отсутствие означает, что рукопожатие не состоялось, и
дальше идти нельзя.

```bash
ip route get 10.0.0.1
```

**Ожидается:** `10.0.0.1 dev wg0 src 10.0.0.10`. Вывод с другим интерфейсом означает пересечение
подсетей — см. отказы.

```bash
ping -c 3 10.0.0.1                   # ожидается: 3 packets transmitted, 3 received
ping -M do -s 1392 -c 3 10.0.0.1     # ожидается: 3 received, без "Frag needed"
```

Второй `ping` проверяет проходимость пакета полного размера: 1392 байта данных плюс заголовки дают
1420 — это MTU интерфейса `wg0` по умолчанию. Первая команда прошла, вторая нет — раздел про MTU в
отказах.

**Проверка — на концентраторе:**

```bash
sudo wg show wg0 latest-handshakes    # ожидается: у пира не 0
ping -c 3 10.0.0.10                   # ожидается: 3 received
```

---

## Шаг 8. Клиент внутри контейнера рядом с сервисом

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

`vpn/Dockerfile`:

```dockerfile
FROM linuxserver/wireguard:latest

ENV PUID=1000 \
    PGID=1000 \
    TZ=UTC

# образ обращается к этому каталогу при старте, проверяя наличие модуля ядра
RUN mkdir -p /lib/modules && chmod 755 /lib/modules
```

Конфигурация в образ не копируется, а монтируется: слой образа с закрытым ключом попадает в кэш
сборки и в реестр, если образ туда отправляют. Файл `vpn/wg0.conf` — тот же, что в шаге 7, с
адресом этого сервера; в системе контроля версий он не хранится.

Compose-файл микросервиса:

```yaml
networks:
  host_net:
    driver: bridge
    ipam: { config: [{ subnet: 10.20.2.0/24 }] }
  vpn_net:
    driver: bridge
    ipam: { config: [{ subnet: 10.21.2.0/24 }] }

services:
  vpn_client:
    build: { context: ., dockerfile: vpn/Dockerfile }
    networks:
      vpn_net: { ipv4_address: 10.21.2.10 }
    cap_add: ["NET_ADMIN"]
    devices: ["/dev/net/tun:/dev/net/tun"]
    sysctls:
      - net.ipv4.conf.all.src_valid_mark=1
    volumes:
      - ./vpn/wg0.conf:/config/wg0.conf:ro
    restart: unless-stopped
    command: >
      sh -c "iptables -t nat -A POSTROUTING -s 10.21.2.0/24 -o wg0 -j MASQUERADE
             && tail -f /dev/null"

  backend:
    build: { context: ., dockerfile: source/Dockerfile }
    networks:
      host_net: { ipv4_address: 10.20.2.100 }
      vpn_net:  { ipv4_address: 10.21.2.100 }
    expose: ["8000"]
    depends_on: [vpn_client]
```

Вход в микросервис со стороны туннеля — отдельный nginx, подключённый к сетевому пространству
клиента (`network_mode: "service:vpn_client"`). Он описан в
[сетевом контуре](./network-topology.md) и здесь не повторяется.

### Что делает `command`

Правило MASQUERADE переписывает адрес отправителя у пакетов, уходящих из моста `vpn_net` в
туннель. Без него сервер на другом конце получил бы пакет с адресом `10.21.2.100` — адресом
локального моста чужой машины, маршрута к которому у него нет, и ответ никуда бы не ушёл. С
правилом обращения приходят с адреса `10.0.0.10`, то есть с адреса этого сервера в туннеле.

Правило ссылается на `wg0`, поэтому может быть добавлено только после подъёма интерфейса.
Инициализация образа поднимает `wg0` из `/config/wg0.conf` до запуска команды, отдельный вызов
`wg-quick` не нужен. `tail -f /dev/null` удерживает команду запущенной: её завершение остановило бы
контейнер, а вместе с ним и сетевое пространство, в котором живёт nginx.

### Права: почему `NET_ADMIN`, а не `privileged`

| Что нужно клиенту | Чем выдаётся | Зачем |
|---|---|---|
| создать интерфейс, задать адрес и маршруты, править `iptables` в своём пространстве | `cap_add: ["NET_ADMIN"]` | это и есть работа `wg-quick` |
| устройство `/dev/net/tun` | `devices` | нужно резервной реализации в пространстве пользователя, когда модуль ядра недоступен |
| параметр `net.ipv4.conf.all.src_valid_mark` | `sysctls` в Compose | `wg-quick` устанавливает его сам, когда туннель забирает маршрут по умолчанию; контейнер без полных привилегий сделать этого не может, поэтому значение задаётся снаружи |

`privileged: true` выдаёт все возможности сразу, доступ ко всем устройствам хоста и ослабляет
профили seccomp и AppArmor. Из этого набора клиенту нужны две позиции из таблицы выше.

Возможность `SYS_MODULE` в список не входит намеренно: она нужна только для загрузки модуля ядра
изнутри контейнера и равносильна праву загрузить в ядро хоста произвольный код. Модуль
`wireguard` входит в ядро с версии 5.6; если он не загружен, загрузите его на хосте:

```bash
sudo modprobe wireguard
echo wireguard | sudo tee /etc/modules-load.d/wireguard.conf
```

### Исходящие обращения от backend

Если микросервис только отвечает на запросы, шаг закончен: входящие приходят через nginx, который
уже находится в сетевом пространстве клиента. Если backend сам обращается к другим машинам контура,
ему нужен маршрут в туннель через контейнер клиента:

```yaml
  backend:
    cap_add: ["NET_ADMIN"]
    command: >
      sh -c "ip route replace 10.0.0.0/24 via 10.21.2.10
             && exec python -m source.app.main"
```

Возможность `NET_ADMIN` здесь выдаётся ради одной команды `ip route`. Микросервису, который никуда
не обращается сам, ни маршрут, ни возможность не нужны — не добавляйте их «на всякий случай».

**Проверка:**

```bash
docker compose up -d
docker compose exec vpn_client wg show
```

**Ожидается** тот же вывод, что в шаге 7: интерфейс, пир концентратора и непустой
`latest handshake`.

```bash
docker compose exec vpn_client ip route          # ожидается строка: 10.0.0.0/24 dev wg0
docker compose exec vpn_client iptables -t nat -L POSTROUTING -n
docker compose exec vpn_client ping -c 3 10.0.0.1
```

**Ожидается** во второй команде правило `MASQUERADE ... 10.21.2.0/24 ...` на интерфейсе `wg0`, в
третьей — три полученных пакета. Ответ `ping: not found` означает только, что в образе нет этой
программы: тогда признак связности — растущие счётчики `transfer` в `wg show` при повторном вызове.

Если настраивали маршрут для backend:

```bash
docker compose exec backend ip route get 10.0.0.1   # ожидается: via 10.21.2.10
```

Клиент из шага 7 и клиент в контейнере — два способа сделать одно и то же на одной машине. Работать
должен один: два интерфейса `wg0` с одним и тем же адресом пира дадут отбрасывание пакетов на
концентраторе. Переходя на контейнер, снимите хостовый клиент: `sudo systemctl disable --now
wg-quick@wg0`.

---

## Проверка всей цепочки

Снизу вверх, с сервера шлюза:

```bash
# 1. пир поднят и обменивается данными
sudo wg show                                   # ожидается: latest handshake, ненулевой transfer

# 2. адрес второй машины отвечает
ping -c 3 10.0.0.10                            # ожидается: 3 received

# 3. вход в микросервис отвечает по адресу туннеля
curl -sf http://10.0.0.10:8000/health && echo OK

# 4. адрес микросервиса недоступен снаружи контура — с машины вне туннеля
curl -m 5 -I http://10.0.0.10:8000/health      # ожидается: таймаут
```

| Первая неудавшаяся проверка | Где искать |
|---|---|
| 1 | рукопожатие: порт, `Endpoint`, ключи — шаги 4–7 |
| 2 | адресация и маршруты: `AllowedIPs`, пересечение подсетей, пересылка на концентраторе |
| 3 | nginx микросервиса не запущен или не в сетевом пространстве клиента — [сетевой контур](./network-topology.md) |
| 4 | адрес частной сети маршрутизируется извне — план адресов пересекается с реальной сетью провайдера |

---

## Типичные отказы

| Признак | Причина | Что делать |
|---|---|---|
| в `wg show` у пира нет строки `latest handshake`, `transfer: 0 B received` | пакеты не доходят до концентратора | на концентраторе: `sudo ss -ulpn \| grep 51820` и `sudo ufw status`; проверить облачный экран провайдера; сверить `Endpoint` в конфигурации клиента |
| `latest handshake` есть, но `0 B sent` или `0 B received` | несовпадение `AllowedIPs`: на концентраторе не тот адрес пира либо на клиенте подсеть не покрывает адресата | `sudo wg show wg0 allowed-ips` на обоих концах; на концентраторе у пира — `/32` его адреса, на клиенте — подсеть туннеля |
| концентратор пингуется, второй пир — нет | не включена пересылка или нет правила `FORWARD` | `sysctl net.ipv4.ip_forward` (ожидается `1`), `sudo iptables -L FORWARD -n -v --line-numbers` — правило `wg0 → wg0` должно быть выше правил `ufw` |
| `ping` проходит, `curl` отдаёт заголовки и виснет; большие ответы обрываются, маленькие проходят | MTU: внешний пакет 1500 байт не проходит по пути, а признак «не фрагментировать» запрещает его разрезать | задать `MTU = 1380` в `[Interface]` **на обоих концах**, перезапустить интерфейс, проверить `ip link show wg0` и `ping -M do -s 1352`; при отказе снижать до 1280 |
| `ip route get <адрес туннеля>` показывает не `dev wg0` | подсеть туннеля пересекается с локальным мостом Docker или с сетью провайдера | сменить план адресов или подсети в `ipam`, пересоздать сети: `docker compose down && docker network prune` |
| `wg-quick up wg0` завершается с `RTNETLINK answers: File exists` | интерфейс уже поднят — вручную или предыдущим запуском службы | `sudo wg-quick down wg0`, затем `sudo systemctl start wg-quick@wg0`; пользоваться одним способом запуска |
| после добавления пира оборвались соединения остальных | интерфейс перезапускали целиком | применять `sudo wg syncconf wg0 <(sudo wg-quick strip wg0)` |
| туннеля нет после перезагрузки | служба не включена в автозапуск | `sudo systemctl enable wg-quick@wg0` |
| контейнер клиента запускается, но `wg show` внутри пуст | нет `NET_ADMIN` или `/dev/net/tun` | добавить `cap_add` и `devices`, смотреть `docker compose logs vpn_client` |
| обращения приходят с адреса `10.21.N.100`, а не с адреса туннеля | нет правила MASQUERADE в контейнере клиента | проверить `iptables -t nat -L POSTROUTING -n` внутри контейнера; правило добавляется после подъёма `wg0` |
| backend не может обратиться к другой машине контура, хотя туннель поднят | у backend нет маршрута в подсеть туннеля | `ip route replace 10.0.0.0/24 via 10.21.N.10` в контейнере backend с `cap_add: ["NET_ADMIN"]` |
| рукопожатия нет, ключи и порт верны | расхождение часов: метка времени в рукопожатии не принимается | `timedatectl` на обеих машинах, включить синхронизацию |
| закрытый ключ попал в репозиторий, слой образа или переписку | ключ считается скомпрометированным независимо от того, воспользовался ли им кто-то | см. «Отзыв ключа» ниже |

### Отзыв ключа

Смена ключа затрагивает обе стороны, порядок — от концентратора к пиру, чтобы старый ключ перестал
приниматься до того, как начнётся возня с конфигурацией сервера.

```bash
# на концентраторе: убрать блок [Peer] со старым ключом из /etc/wireguard/wg0.conf
sudo wg syncconf wg0 <(sudo wg-quick strip wg0)
sudo wg show wg0 allowed-ips        # ожидается: старого ключа в выводе нет

# на пире: новая пара
wg genkey | sudo tee /etc/wireguard/private.key >/dev/null
sudo chmod 600 /etc/wireguard/private.key
sudo sh -c 'wg pubkey < /etc/wireguard/private.key > /etc/wireguard/public.key'

# на концентраторе: новый блок [Peer] с тем же адресом, снова syncconf
```

Что нужно сделать дополнительно, если ключ утёк через образ: пересобрать образ без старого файла и
удалить прежние слои (`docker image prune -a` на машинах, где образ есть). Слой с ключом остаётся в
кэше сборки и в реестре после того, как из репозитория файл убран.

---

## Откат и снятие

Снять клиента в контейнере:

```bash
docker compose stop vpn_client
docker compose rm -f vpn_client
```

Сервисы, у которых `network_mode: "service:vpn_client"`, останавливаются вместе с ним: их сетевое
пространство принадлежит удалённому контейнеру. Уберите эти сервисы из Compose-файла или переключите
их на локальный вход — в [сетевом контуре](./network-topology.md) для этого рядом с `vpn_nginx`
лежит закомментированный `host_nginx`.

Снять клиента на хосте:

```bash
sudo systemctl disable --now wg-quick@wg0
ip link show wg0        # ожидается: Device "wg0" does not exist
```

Отозвать ключ на концентраторе — по разделу «Отзыв ключа»: блок `[Peer]` удаляется, `wg syncconf`
применяет; после этого машина с этим ключом в туннель не входит.

Полностью снять концентратор:

```bash
sudo systemctl disable --now wg-quick@wg0
sudo ufw delete allow 51820/udp
sudo rm /etc/sysctl.d/99-wireguard.conf && sudo sysctl --system
```

Вернуть прямой доступ к микросервису, пока частной сети нет: опубликовать порт его входа на
loopback и обращаться к нему с этой же машины либо через SSH-туннель. Публикация на всех
интерфейсах открывает сервис интернету — если она понадобилась, снимайте её сразу после проверки.

Что **не** удаляется перечисленными командами:

| Остаётся | Где | Что с этим делать |
|---|---|---|
| `/etc/wireguard/wg0.conf` и файлы ключей | на каждой машине | закрытые ключи удалять отдельно: `sudo sh -c 'shred -u /etc/wireguard/*.key /etc/wireguard/wg0.conf'` — раскрытие маски выполняет `sh` под `sudo`, потому что каталог закрыт для чтения обычному пользователю |
| правила `iptables` из `PostUp` | таблица `filter`, цепочка `FORWARD` | снимаются `PostDown` при остановке службы; после ручного запуска правил — удалить командой из `PostDown` |
| сети Compose | Docker | `docker network prune` после остановки проектов |
| образ клиента с вкомпилированной конфигурацией | локальный кэш образов | `docker image prune -a` |

Пакеты `wireguard` и `wireguard-tools` удалять не нужно: без интерфейса и конфигурации они ничего
не делают, а их наличие не открывает портов.
