DarkRiDDeR14 мин

Почему контейнер не делает окружение одинаковым: файлы, сеть и переменные

DockerИнфраструктураРазбор

Симптом выглядит противоречиво: контейнер api запущен, но не видит базу; переменная есть в терминале, но отсутствует в приложении; файл лежит рядом с Dockerfile, а процесс сообщает ENOENT. Цена — ложная уверенность, что Docker сделал среду одинаковой. На деле одинаковым стал только образ процесса, а границы между хостом, daemon, контейнером и сетью остались выбранными вручную.

Разбор начинаю с пути одного запроса, а не с команды «перезапусти compose». Браузер на хосте идёт в опубликованный порт API. API читает свои переменные и обращается к db по внутренней bridge-сети. PostgreSQL читает данные из named volume. В этой цепочке четыре разных адреса и владельца состояния. Если записать их отдельно, проверка становится короткой: выяснить, на какой стрелке потерялось значение, а не менять Dockerfile вслепую.

Четыре пространства, которые нельзя склеивать в голове

Первое пространство — рабочая копия на машине разработчика. Второе — машина, на которой запущен Docker daemon; в обычной локальной установке они часто совпадают, но это не обещание протокола. Третье — файловая система контейнера. Четвёртое — сеть контейнеров. Bind mount связывает первые два с третьим конкретным путём. Сервисное имя связывает процесс с четвёртым пространством. Переменные живут ещё в одном, процессном, слое: оболочка Compose может подставить строку в YAML, а затем только явно переданное значение попадёт в process.env.

Из этого следует практическое правило: слово localhost нельзя использовать без указания наблюдателя. В браузере на хосте localhost:3000 обычно ведёт к опубликованному порту контейнера. Внутри api тот же адрес ведёт обратно в api, а не к PostgreSQL. Для базы в Compose-стеке адресом является db. Если одна строка подключения используется и в host-script, и в контейнере, это не удобство, а два несовместимых контекста, которые надо назвать разными конфигурациями.

Один параметр — разные наблюдатели
Что видимГде наблюдаемЧто это подтверждаетЧего не подтверждает
localhost:3000 отвечает HTMLбраузер или curl на хостепорт web или api опубликован на хостчто другой контейнер может обратиться к localhost:3000
db разрешается в адреспроцесс или shell внутри apiвнутреннее имя сервиса доступно в его сетичто PostgreSQL уже принимает логин
DATABASE_HOST=db есть в configdocker-compose configCompose собрал ожидаемую строку конфигурациичто приложение использует эту переменную после старта
Файл есть в рабочей копиитерминал хостаисходник сохранён на хостечто он смонтирован по пути, который читает процесс контейнера

Последняя колонка полезнее набора советов. Она не даёт сделать лишний вывод из удачной команды. Увидеть db в docker-compose config — не то же самое, что увидеть успешный TCP-connect. Увидеть контейнер в ps — не то же самое, что прочитать переменную его процессом. Каждый следующий шаг обязан идти в соседнее пространство, а не повторять предыдущий в другой форме.

Compose описывает связи, но не исполняет приложение

Конфигурация ниже оставляет все связи явными. У API есть рабочий путь, bind mount, команда запуска и две переменные для базы. У базы — образ, собственные переменные и named volume. В файле нет опубликованного порта PostgreSQL, потому что клиент базы — контейнер api, а не браузер на хосте. Это уменьшает число доступных адресов и делает ошибку адреса заметнее.

version: '3.7'

services:
  api:
    build: .
    working_dir: /srv/api
    command: node server.js
    environment:
      NODE_ENV: development
      DATABASE_HOST: db
      DATABASE_PORT: '5432'
    volumes:
      - .:/srv/api
    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:
  postgres_data:

Строка DATABASE_HOST=db — не магический DNS. Compose создаёт локальную сеть для сервисов проекта, а имя сервиса используется в этой сети. Если API оказался запущен отдельной командой docker run или присоединён к другой сети, эта предпосылка исчезает. Поэтому в расследовании сначала сверяют, как именно был создан процесс: через тот же Compose-проект или обходным способом. Не стоит добавлять вручную IP адрес контейнера в .env: он относится к текущему запуску, а не к контракту сервиса.

Поле depends_on в этом примере выражает порядок: база создаётся раньше API. Оно не является доказательством готовности PostgreSQL обработать запрос именно в момент запуска Node. База может ещё выполнять инициализацию, миграция может не завершиться, а пользователь базы может оказаться неверным. Надёжная локальная проверка проще, чем обещание: API должен записать успешное подключение в лог либо один известный endpoint должен отработать после старта. Если этого признака нет, команда оставляет состояние «контейнер жив» и не называет его «среда готова».

Вертикальная схема пути запроса: браузер на хосте обращается к опубликованному порту API, API получает DATABASE_HOST=db и соединяется с PostgreSQL по внутренней сети, данные базы остаются в named volume
Один запрос проходит через разные пространства: хостовый порт, процесс API, внутреннее имя сервиса и persistent volume базы. Стрелки нельзя заменить единым localhost.

Подстановка в YAML и окружение процесса — не одно событие

Compose может взять значение из окружения запускающей оболочки или файла .env, чтобы собрать конфигурацию. Это ещё не значит, что приложение увидит его. Переменная становится доступной процессу контейнера, когда она объявлена через environment или подключена способом, который явно описан в конфигурации. Поэтому проверку делаю в два прохода. Сначала docker-compose config показывает итоговый YAML без догадки о подстановке. Затем диагностика внутри api выводит только безопасный факт — например, наличие DATABASE_HOST и его не-секретное значение.

Пароль не стоит проверять командой, которая печатает всё окружение в общий лог. Локальная среда не делает секреты безопасными от копирования в историю терминала или issue. Для учебного PostgreSQL-пароля в примере можно честно назвать его тестовым. Для настоящего проекта нужно определить отдельный способ передачи чувствительных значений, список разрешённых мест хранения и правило, какие поля не попадают в diagnostic output. Это не зрелая платформа секретов, а базовая средовая дисциплина: не путать «значение передали» с «значение показали всем».

Файлы движутся не так, как кажется

Если api стартует в /srv/api, а bind mount привязан к /app, код с хоста формально смонтирован, но процесс его не читает. Такое часто происходит после копирования compose-файла между проектами. Проверка проста: сопоставить working_dir, путь в command, путь в Dockerfile и целевой путь mount. Для Node-проекта нужно отдельно назвать судьбу node_modules: host-каталог, слой образа и named volume ведут себя по-разному.

Named volume для PostgreSQL решает другой вопрос: данные переживают пересоздание контейнера. Это полезно для обычного локального дня, но опасно для диагностики, если команда ожидает пустую базу после каждого up. Не нужно объявлять volume «кешем Docker» и удалять его при каждой ошибке. Правильнее записать, какие таблицы или миграции остаются в нём, и иметь отдельный явно разрушительный сценарий чистого старта. Тогда данные не исчезают случайно, а старое состояние не выдаётся за новую конфигурацию.

Маршрут диагностики без догадок

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

  1. Назвать один клиент и один ожидаемый ответ: браузер на хосте, Node-процесс в api или PostgreSQL; не писать просто «проверить localhost».
  2. Собрать docker-compose config и сверить итоговые volumes, working_dir, environment, depends_on и сервисное имя базы.
  3. После docker-compose up -d проверить состояние контейнеров и логи, не принимая статус Up за подтверждение готовности зависимости.
  4. Изнутри api проверить только нужный слой: разрешение имени db, наличие безопасной переменной или доступность конкретного endpoint.
  5. Если связь не прошла, изменить один адрес, mount или переменную, повторить тот же запрос и записать, какая стрелка схемы изменилась.
  6. Отдельно договориться о чистом запуске для volume: когда его разрешено удалить и чем подтверждается пустое состояние.

Итог: контейнер изолирует процесс, а не договорённости

Docker делает локальный процесс воспроизводимее, но не выбирает за проект, какой путь считать рабочим, где хранить данные и как один сервис находит другой. Эти решения остаются в Compose-файле, Dockerfile, переменных и запуске. Самая полезная привычка — проговаривать наблюдателя: хост, daemon, контейнер API или сеть сервисов. Тогда localhost, путь файла и значение переменной перестают быть двусмысленными.

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

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

  • 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: environment variables — разделяет подстановку значений в Compose-файл и переменные, которые действительно попадают в окружение процесса контейнера
  • Docker Compose: startup and shutdown order — порядок запуска сервисов не равен готовности приложения принимать соединения; готовность нужно проверять отдельным наблюдением