open atlas
↑ К треку
Docker: контейнеры как система DOCK · 06 · 03

Dev-цикл: override, watch/sync, profiles и недрейфование от prod

Продуктивный dev-цикл Compose слоит базовый файл с override для живой перезагрузки (develop.watch sync/rebuild), прячет опциональные сервисы за profiles и относится к compose-файлу как к контракту, удерживающему dev от дрейфа от prod. У bind mount и watch своя цена.

DOCK Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Баг существовал только в проде, и существовал из-за доброты в dev-настройке. Чтобы ускорить внутренний цикл, команда bind-смонтировала каталог исходников хоста прямо поверх каталога приложения в контейнере — правишь файл, изменение мгновенно появляется внутри контейнера, без пересборки. Это прекрасно работало месяцами. Потом в package.json добавили зависимость, установили локально, и приложение нормально работало на ноутбуке каждого разработчика. CI собрал образ чисто. Прод упал на старте с ошибкой missing-module. Причина — bind mount: он затенил node_modules контейнера — собранные во время docker build с правильной архитектурой и полным деревом зависимостей — node_modules хоста, в которых случайно уже был новый пакет, потому что кто-то запустил npm install на хосте. Dev не запускал образ, который собрал CI; он запускал файлы хоста, наброшенные на оболочку контейнера. Install-шаг Dockerfile был мёртвым кодом месяцами, никогда не выполнялся, тихо гнил, пока прод не запустил реальный образ и не обнаружил, что install неверен. Фикс сохранил быстрый цикл, но перестал об этом врать: именованный том для node_modules, чтобы install образа пережил bind mount, а позже переход на watch Compose, чтобы контейнер запускал собранное, а синки были явными.

Override-файлы: одна база, много окружений

Compose автоматически сливает два файла при их наличии: compose.yaml (базовое, общее, prod-образное определение) и compose.override.yaml (только локальные правки), с override, глубоко слитым поверх базы. Это механизм, позволяющий одному каноническому файлу описывать топологию, пока локальные заботы — debug-порт, bind mount, ослабленный уровень логов — живут в отдельном файле, который можно даже gitignore-нуть. У правил слияния есть senior-тонкость: отображения сливаются, но большинство последовательностей замещают. Две карты environment объединяются ключ-за-ключом; но command: или entrypoint: в override замещает базу целиком, а поля-списки вроде ports: конкатенируются именно для портов, тогда как многие другие списки замещают. Безопасная ментальная модель: скаляры и ключи override побеждают, карты глубоко сливаются, а всё списочное считай замещающим, пока не проверил. Можно быть явным через -f base.yaml -f dev.yaml, контролируя, какие файлы сливаются и в каком порядке — порядок важен, поздние файлы побеждают.

# compose.yaml — база, prod-образная, без монтирования исходников и debug-портов
services:
  api:
    build: .
    environment:
      LOG_LEVEL: info
    ports:
      - "8080:8080"

# compose.override.yaml — только локальный dev, глубоко слит поверх
services:
  api:
    environment:
      LOG_LEVEL: debug      # сливается в карту environment базы
    ports:
      - "9229:9229"          # порт дебаггера, конкатенируется с 8080
    develop:
      watch:
        - action: sync       # копировать изменённые файлы в работающий контейнер
          path: ./src
          target: /app/src
        - action: rebuild    # изменения здесь форсируют пересборку образа
          path: ./package.json

watch/sync против bind mount: живая перезагрузка без лжи

Старый быстрый цикл был bind mount исходников хоста поверх пути контейнера. Это мгновенно, но это ровно ловушка из Hook: каталог хоста замещает каталог контейнера, поэтому всё, что образ собрал в этот путь — node_modules, скомпилированные ассеты, venv — спрятано за файлами хоста, и dev тихо перестаёт запускать то, что содержит образ. develop.watch Compose — современная альтернатива, и она инвертирует модель: вместо монтирования хоста поверх контейнера она следит за путями хоста и реагирует на изменения. Действие sync копирует изменённые файлы в работающий контейнер (обычно меньше секунды, ~100–300ms для маленького файла) без затенения вывода сборки образа, так что hot-reloader внутри контейнера их подхватывает — быстрый цикл, но контейнер всё ещё запускает свои собранные зависимости. Действие rebuild для изменений, которые нельзя горячо пропатчить: правишь package.json или Dockerfile, и watch пересобирает образ и пересоздаёт контейнер (секунды-десятки секунд в зависимости от кеша). Вариант sync+restart копирует, затем перезапускает процесс для кода, которому нужен чистый рестарт, а не hot reload. Дисциплина, которую это даёт: dev запускает реальный образ с явным, видимым синком исходников — install-шаг выполняется при каждом изменении зависимостей, а не гниёт.

Викторина

Команда bind-монтирует хостовый ./ поверх /app для мгновенных правок. После добавления зависимости приложение работает локально, но падает в проде с missing module. Что сделал bind mount?

Почему это работает

Почему watch лучше bind mount, хотя оба дают быстрый цикл? Потому что они различаются тем, что dev реально выполняет. Bind mount заставляет dev запускать файлы хоста поверх контейнера, поэтому собственный вывод сборки контейнера — его установленные зависимости, скомпилированные артефакты — спрятан, и сборка образа становится непроверенной, пока что-то ниже по потоку не запустит реальный образ. Watch заставляет dev запускать собранный образ и копирует твои изменения исходников в него; install зависимостей всё ещё происходит в образе и выполняется при каждом rebuild. Bind mount оптимизирует цикл, убирая образ из цикла; watch оптимизирует цикл, сохраняя образ в нём. Первый быстрее в настройке и тихо создаёт дрейф dev/prod; второй стоит немного конфига и держит dev честным. Падение missing-module из Hook — это счёт за первый вариант, оплаченный в проде.

Profiles: опциональные сервисы выключены по умолчанию

Большой стек часто несёт сервисы, нужные не всем каждый день — стек метрик, mail-catcher, засеянная тестовая фикстура, admin-UI. Помещение их в базовый файл значит, что каждый платит их стоимость старта и след по ресурсам на каждом up. Profiles прячут сервис так, что он стартует только когда его профиль явно активирован: сервис с тегом profiles: ["debug"] не стартует на обычном compose up; он стартует только с --profile debug или когда назван напрямую. Сервисы без ключа profiles стартуют всегда — это твой ядровый набор. Senior-подвох — взаимодействие зависимостей: если всегда-включённый сервис depends_on профилированный, профилированный подтягивается неявно для удовлетворения зависимости, что может удивить запуском того, что ты считал выключенным. Profiles держат дефолтный up лёгким — поднять три реально нужных сервиса за пару секунд — сохраняя опциональные восемь в одном флаге, всё в одном файле. Альтернатива, отдельные compose-файлы под сценарий, фрагментирует топологию и переоткрывает дрейф; profiles держат один файл единственным источником истины.

Викторина

Сервис метрик помечен profiles: [observability], чтобы быть выключенным по умолчанию. Разработчик делает обычный compose up и удивлён, что тот всё равно стартует. Какова наиболее вероятная причина?

Вспомните перед уходом
  1. 01
    Почему bind mount исходников хоста поверх пути контейнера вызывает дрейф dev/prod и как watch Compose этого избегает?
  2. 02
    Объясни слияние override и profiles в Compose, включая подвох каждого.
Итог

Хороший локальный цикл оптимизирует скорость без тихого создания дрейфа dev/prod. Начни с одного prod-образного базового файла и наслои глубоко слитый compose.override.yaml для локальных забот — debug-порты, ослабленное логирование, конфиг watch — помня асимметрию слияния: карты объединяются ключ-за-ключом, тогда как списочные поля вроде command и entrypoint замещают, а ports конкатенируются, поэтому считай список замещающим, пока не проверил. Для живой перезагрузки предпочти develop.watch Compose bind mount-у: bind mount накладывает файлы хоста на контейнер и затеняет то, что образ туда собрал, поэтому dev запускает файлы хоста поверх оболочки, а install-шаг образа гниёт непроверенным, пока прод не запустит реальный образ и не упадёт на missing module — счёт из Hook. Watch вместо этого запускает собранный образ и реагирует на изменения хоста: sync копирует исходники за ~100-300ms без затенения, rebuild срабатывает при смене package.json или Dockerfile, а sync+restart перезапускает процесс, когда hot reload недостаточно — так быстрый цикл сохраняет образ, и install выполняется при каждой смене зависимостей. Наконец, profiles прячут опциональные сервисы от дефолтного up, чтобы старт был лёгким, с одним подвохом: всегда-включённый depends_on может неявно подтянуть профилированный сервис. Через все три красная нить в том, что compose-файл остаётся единственным источником истины, а dev-удобства наслаиваются поверх, а не форкают топологию. Теперь, когда откроешь репо и увидишь bind mount ./ поверх /app — ты сразу знаешь, где искать дрейф, и какие две строки конфига watch это исправят, не замедлив цикл.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.