# Образ собирается локально и едет на сервер через docker save/load

- **Дата:** 2026-07-24
- **Источник:** архивного `design.md` нет — решение деплойное, change'ем jellybit
  не велось

## Контекст

Заменяет [ADR-2026-06-13-docker-deploy](ADR-2026-06-13-docker-deploy.md).

Docker как единица деплоя и распределение ответственности (`Dockerfile`
— упаковка — живёт в jellybit; оркестрация — в umbar) остаются в силе.
Пересматривается только **как** образ попадает на сервер.

Прежняя схема собирала статический бинарь на control-хосте, копировала
на сервер бинарь + `Dockerfile` и делала `docker build` **на месте**.
У этого два неудобства. Во-первых, на сервере остаётся шаг сборки: пусть
дешёвый на Intel N150, но результат косвенно завязан на состояние сервера
(его docker, его кэш), а не только на исходник. Во-вторых, схема
одноразовая — она была вписана в `playbook-jellybit.yml` и не
переиспользовалась. Появление второго такого же приложения (trackers)
потребовало вынести доставку образа в общую ansible-роль `app_image` и
заодно унифицировать контракт с приложением.

## Рассмотренные варианты

- **Оставить сборку на сервере** — прежнее решение. Держит на сервере шаг
  `docker build` и делает результат зависимым от состояния сервера;
  переиспользовать без копипасты плейбука неудобно.
- **Реестр (CI пушит образ, сервер тянет)** — каноничнее, но в домашней
  лаборатории это лишний реестр и пайплайн ради одного узла. Отвергнуто по
  той же причине, что и в исходной ADR.
- **Собрать полный образ локально и доставить его `docker save`/`load`** —
  выбрано: сборка целиком на control-хосте, сервер — только получатель, и
  реестр не нужен.

## Решение

Доставку образа ведёт переиспользуемая роль `app_image` в umbar. Схема:

- **Контракт с приложением.** Приложение реализует команду `task image`:
  получает `BUILD_ID` из окружения и собирает **полный** образ
  `<app>:$BUILD_ID`. Без `BUILD_ID` собирается `<app>:dev` — обратная
  совместимость для локальной работы. Приложение полностью владеет тем,
  как собирается его образ (`Dockerfile` — в репозитории приложения).
- **Сборка локальна.** Роль генерит случайный `BUILD_ID` и гоняет с ним
  `task image` на control-хосте → образ `<app>:$BUILD_ID`. На сервере
  Go-тулчейн и `docker build` больше не нужны.
- **Тег = BUILD_ID.** Deploy-тег — тот самый случайный `BUILD_ID`.
  Дедупликации по содержимому нет: тег нов на каждый прогон. Content-адресацию
  (тег из хеша слоёв/конфига образа) рассматривали и отвергли — на сценариях
  ручного нечастого деплоя выгода от неё не окупала сложности.
- **Доставка без реестра.** `docker save` → copy tar → `docker load`.
  Каждый деплой везёт образ и пересоздаёт контейнер.
- **Уборка.** Старые образы на сервере (тег каждого прошлого деплоя)
  подчищает `docker image prune -af` по крону (`playbook-system.yml`).

## Последствия

- `+` Сервер — только получатель образа: без Go-тулчейна и без шага
  `docker build`.
- `+` Сборка целиком на control-хосте — воспроизводимее; состояние
  сервера на результат не влияет.
- `+` Реестр по-прежнему не нужен — доставка дешёвая (`save`/`load` tar).
- `+` Механизм общий (роль `app_image`), а не вписан в один плейбук —
  им же доставляется trackers.
- `-` Дедупликации нет: тег = случайный `BUILD_ID`, поэтому каждый деплой
  везёт образ и пересоздаёт контейнер, даже если ничего не менялось. Для
  ручного нечастого деплоя это осознанный размен — простота роли важнее
  экономии одного рестарта.
- `-` `save`/`load` везёт весь образ (базовый слой + бинарь), а не только
  бинарь, как в прежней схеме. Для `distroless/static` это единицы
  мегабайт — дёшево.
