DarkRiDDeR13 мин

Локальная разработка в Docker: фиксируем окружение вместо «у меня работает»

DockerFrontendПрактика

Симптом знакомый: ветка запускается у автора, а на соседнем ноутбуке фронтенд не видит базу, порт уже занят или зависимости вдруг оказываются «не теми». Цена такой ошибки не сводится к десяти минутам настройки. Новый человек повторяет случайные команды из чата, команда спорит о версии Node вместо задачи, а дефект окружения маскирует реальную правку в коде. Контейнер сам по себе это не лечит: он может лишь спрятать ещё одну неописанную настройку.

Для локальной разработки я фиксирую не фразу «проект в Docker», а маленький контракт: откуда берётся образ, какой код попадает в контейнер, где живут генерируемые файлы и данные, по какому имени сервисы видят друг друга и какие значения получает процесс. Тогда проверка становится конкретной: docker-compose config показывает собранную конфигурацию, docker-compose ps показывает состояние, а один HTTP-запрос и лог связывают конфигурацию с поведением. Это учебный сценарий января 2020 года на Docker Engine 19.03 и отдельном CLI docker-compose; его границы не расширяются инструментами другой эпохи.

Окружение состоит из пяти наблюдаемых слоёв

Образ отвечает за базовую файловую систему и установленный runtime. Контейнер — за один запущенный процесс с его переменными и файловой системой на момент старта. Bind mount подставляет в контейнер каталог рабочей копии; named volume хранит данные под управлением Docker. Сеть даёт сервисам внутренние имена, а публикация порта связывает порт контейнера с портом хоста. Пока эти вещи называют словом «контейнер», причина сбоя остаётся неясной.

Например, код меняется на хосте, но web читает старую сборку. Это не обязательно ошибка watch-режима. Сначала нужно выяснить, есть ли bind mount к тому пути, откуда процесс читает исходники, и не перекрывает ли другой mount нужную директорию. Если браузер открывает localhost:3000, а сервис web пытается подключиться к localhost:5432, проблема не в публикации порта базы. Внутри контейнера localhost означает тот же контейнер; для базы нужен адрес db из локальной сети Compose.

Карта локального контракта: что наблюдаем до попытки «переустановить всё»
НаблюдениеВероятная границаПроверкаДействие
Код изменён, но dev-server отдаёт старый ответпуть исходников и bind mountсверить working_dir, путь процесса и volumes в docker-compose configпримонтировать рабочую копию к фактическому рабочему каталогу и перезапустить только web
В браузере порт открыт, но API недоступенпубликация порта или внутренний URLразделить запрос с хоста к localhost и запрос из web к имени api или dbоставить ports только для входа с хоста, сервисные адреса задать именами сервисов
После смены ветки ломаются модулиhost node_modules смешан с Linux-окружением контейнерапосмотреть, какой mount покрывает /srv/app/node_modulesоставить зависимости в named volume, а исходники — в bind mount
База хранит старую схему после нового upименованный volumeсопоставить имя volume и путь данных PostgreSQLприменить миграцию либо осознанно очистить локальный volume в отдельном сценарии

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

Один Compose-файл вместо набора личных команд

Ниже минимальная конфигурация для фронтенда с PostgreSQL. Она намеренно не пытается описать production. В ней есть два сервиса, один порт для браузера, один внутренний адрес базы, bind mount для исходников и два named volume: один изолирует зависимости контейнера от каталога хоста, второй делает состояние базы явным. Номера образов зафиксированы как пример, чтобы обновление не происходило незаметно во время расследования.

version: '3.7'

services:
  web:
    build: .
    working_dir: /srv/app
    command: npm run dev
    ports:
      - '3000:3000'
    environment:
      APP_ENV: local
      DATABASE_URL: postgres://app:app@db:5432/app
    volumes:
      - .:/srv/app
      - node_modules:/srv/app/node_modules
    depends_on:
      - db

  db:
    image: postgres:12.1-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  node_modules:
  postgres_data:

Поле version: 3.7 здесь не декоративно. В начале 2020 года проекты на Compose v1 обычно выбирали формат 2.x или 3.x и запускали его отдельной командой docker-compose. Поэтому в этом материале важен именно такой контракт: YAML описывает сервисы, а отдельный CLI собирает и запускает их. Другие инструменты и другой синтаксис не делают пример понятнее, пока их появление не стало частью практики проекта.

Поле build: . означает только, что образ web собирается из Dockerfile текущего проекта. Оно не гарантирует, что пакетный менеджер уже установил зависимости, а command: npm run dev не гарантирует готовность базы. Эти предположения нужно записать рядом с Dockerfile и скриптами проекта. В данной конфигурации DATABASE_URL указывает на db, потому что имя сервиса используется внутри локальной сети; порт PostgreSQL наружу не опубликован и не нужен браузеру.

Вертикальная схема локального Docker-окружения: рабочая копия на хосте монтируется в web, named volumes хранят зависимости и данные PostgreSQL, а web обращается к db по имени сервиса
Контракт локальной среды: код приходит с хоста через bind mount, состояние явно остаётся в named volumes, а сервисы разговаривают по внутреннему имени, не через localhost хоста.

Mount — это договор о владельце файлов

Bind mount удобен потому, что сохраняет обычную работу редактора: файл меняется в рабочей копии, контейнер видит то же изменение по своему пути. Но он не копирует каталог в образ и не делает путь независимым от машины Docker daemon. Документация Docker прямо отделяет путь хоста daemon от пути контейнера и предупреждает, что mount скрывает прежнее содержимое целевой директории. Поэтому путь .:/srv/app надо воспринимать как часть интерфейса проекта, а не как безобидную строчку.

В примере второй mount на /srv/app/node_modules важен не из эстетики. Когда bind mount закрывает весь /srv/app, он может скрыть зависимости, которые были записаны в образе при сборке. Если вместо этого использовать host node_modules, двоичные дополнения и путь исполняемых файлов могут зависеть от операционной системы разработчика. Named volume отделяет эту производную среду от исходников. Он не делает зависимости вечными: при смене lock-файла их всё равно нужно обновить управляемой командой проекта.

Сеть проверяем с двух сторон

У ports: 3000:3000 есть ровно одна задача: дать браузеру на хосте путь к dev-server внутри web. Он не создаёт обратный путь от web к db. Для такого пути Compose создаёт внутреннюю сеть, в которой сервис доступен по имени. Это разделение помогает не публиковать базу только ради приложения и не исправлять строку подключения на localhost, когда ошибка происходит внутри контейнера.

Проверка должна записывать место, из которого сделан запрос. Если curl http://localhost:3000 на хосте возвращает HTML, это подтверждает публикацию порта. Если процесс в web не может открыть db:5432, нужно смотреть DNS-имя, сеть и логи базы, а не браузерный Network. Команда docker-compose exec web полезна именно как смена точки наблюдения: она запускает диагностику в файловой системе и сети web, а не в терминале хоста.

Короткий запуск и критерий готовности

Начинаю с docker-compose config. Эта команда разворачивает подстановки и показывает, какой YAML реально получил Compose; она не проверяет, что приложение успешно подключилось к базе. Затем запускаю docker-compose up -d, смотрю docker-compose ps и беру последние строки docker-compose logs --tail=50 web db. Только после этого открываю один заранее выбранный URL или выполняю один запрос из контейнера. Так у сбоя остаётся время, сервис и точка входа.

Статус «Up» означает, что контейнерный процесс жив, но не доказывает, что сервер слушает порт, миграции завершились или база принимает соединения. depends_on задаёт порядок старта, а готовность зависит от приложения. В учебной среде достаточно, чтобы web логировал успешное подключение и чтобы один запрос к странице прошёл после старта. В реальном проекте критерий стоит оформить отдельной health-проверкой или проверяемой командой, но не надо объявлять её существующей, если её нет в репозитории.

  1. Зафиксировать версию Docker Engine, версию docker-compose и один URL или команду, которые означают «локальная среда готова».
  2. Открыть docker-compose config и сверить build, working_dir, mounts, переменные, сервисные имена и опубликованные порты с договором проекта.
  3. Запустить docker-compose up -d, затем записать вывод docker-compose ps и короткий срез логов каждого зависимого сервиса.
  4. Проверить вход с хоста отдельно от связи между контейнерами: браузер или curl проверяет порт web, диагностика внутри web проверяет имя db.
  5. При сбое назвать один слой из таблицы, сделать обратимое изменение и повторить тот же запрос; не переустанавливать весь стек как первую реакцию.
  6. Сохранить в README только команды и признаки, которые действительно повторены на чистом локальном запуске.

Итог: «одинаковая среда» должна быть проверяемой

Docker полезен локально, когда уменьшает количество личных предположений. Образ, container process, mount, volume, сеть, порт и переменная — разные владельцы состояния. У каждого есть свой наблюдаемый признак. Если описать их одной конфигурацией и проверить одним коротким маршрутом, следующий разработчик начинает не с поисков в истории чата, а с той же границы, что и автор изменения.

Этот пример не запускался в production-среде и не обещает переносимость между любыми ОС, версиями Docker Desktop или удалёнными daemon. Он задаёт более скромную цель: на выбранном проекте увидеть, откуда пришёл код, где остаются данные, каким именем доступна зависимость и какой запрос означает успешный старт. После такой фиксации ошибка «у меня работает» становится вопросом к конкретной строке конфигурации.

Проверяемые источники

  • Docker Engine 19.03 release notes — историческая ветка Engine, актуальная в начале 2020 года; она задаёт границу примеров, а не современный набор возможностей Docker
  • Docker Compose FAQ: различие Compose v1 и v2 — документация фиксирует, что проекты Compose v1 обычно использовали верхнее поле version с форматами 2.x и 3.x; в статье поэтому намеренно используется команда docker-compose и version 3.7
  • Docker Compose: legacy file versions — справочник по историческим форматам Compose 2.x и 3.x, с которыми работал отдельный бинарник docker-compose
  • Docker: bind mounts — граница между путём на машине Docker daemon и путём внутри контейнера, а также риск скрыть содержимое целевой директории монтированием
  • Docker: manage volumes — назначение именованных volumes и отличие управляемого Docker хранилища от файлов рабочей копии
  • Docker: bridge network driver — изолированная bridge-сеть и различие между доступностью контейнеров друг для друга и опубликованными портами хоста
  • Docker Compose: startup and shutdown order — порядок запуска сервисов не равен готовности приложения принимать соединения; готовность нужно проверять отдельным наблюдением