import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '

' + text + '

'; } function heading(text) { return '

' + text + '

'; } function codeBlock(lines) { return '
' + escapeHtml(lines.join('\n')) + '
'; } function figure(src, alt, caption) { return '
' + alt + '
' + caption + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function dataTable(caption, headers, rows) { const captionHtml = '' + caption + ''; const head = '' + headers.map((header) => '' + header + '').join('') + ''; const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; return '
' + captionHtml + head + body + '
'; } function sourceList(items) { return ''; } function visibleText(html) { return html .replace(/<[^>]*>/g, ' ') .replaceAll(' ', ' ') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('&', '&') .replace(/\s+/g, ' ') .trim(); } function proseText(html) { return visibleText( html .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
      .replace(/
[\s\S]*?<\/figure>/g, '') .replace(/
[\s\S]*?<\/div>/g, ''), ); } function createRevision(meta, bodyParts, sources) { const bodyHtml = bodyParts.join('\n'); const proseLength = proseText(bodyHtml).length; if (proseLength < 5000 || proseLength > 15000) { throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength); } if (sources.length < 2) { throw new Error(meta.slug + ': at least two primary or official sources are required'); } return { ...meta, contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'), proseLength, }; } const engine19 = { title: 'Docker Engine 19.03 release notes', url: 'https://docs.docker.com/engine/release-notes/19.03/', note: 'историческая ветка Engine, актуальная в начале 2020 года; она задаёт границу примеров, а не современный набор возможностей Docker', }; const composeV1 = { title: 'Docker Compose FAQ: различие Compose v1 и v2', url: 'https://docs.docker.com/compose/support-and-feedback/faq/', note: 'документация фиксирует, что проекты Compose v1 обычно использовали верхнее поле version с форматами 2.x и 3.x; в статье поэтому намеренно используется команда docker-compose и version 3.7', }; const legacyCompose = { title: 'Docker Compose: legacy file versions', url: 'https://docs.docker.com/reference/compose-file/legacy-versions/', note: 'справочник по историческим форматам Compose 2.x и 3.x, с которыми работал отдельный бинарник docker-compose', }; const bindMounts = { title: 'Docker: bind mounts', url: 'https://docs.docker.com/engine/storage/bind-mounts/', note: 'граница между путём на машине Docker daemon и путём внутри контейнера, а также риск скрыть содержимое целевой директории монтированием', }; const volumes = { title: 'Docker: manage volumes', url: 'https://docs.docker.com/engine/storage/volumes/', note: 'назначение именованных volumes и отличие управляемого Docker хранилища от файлов рабочей копии', }; const bridgeNetwork = { title: 'Docker: bridge network driver', url: 'https://docs.docker.com/engine/network/drivers/bridge/', note: 'изолированная bridge-сеть и различие между доступностью контейнеров друг для друга и опубликованными портами хоста', }; const composeEnvironment = { title: 'Docker Compose: environment variables', url: 'https://docs.docker.com/compose/how-tos/environment-variables/set-environment-variables/', note: 'разделяет подстановку значений в Compose-файл и переменные, которые действительно попадают в окружение процесса контейнера', }; const startupOrder = { title: 'Docker Compose: startup and shutdown order', url: 'https://docs.docker.com/compose/how-tos/startup-order/', note: 'порядок запуска сервисов не равен готовности приложения принимать соединения; готовность нужно проверять отдельным наблюдением', }; const composeDown = { title: 'Docker Compose: down', url: 'https://docs.docker.com/reference/cli/docker/compose/down/', note: 'современное описание удаления контейнеров, сетей и опциональных volumes; историческая команда в статье сохранена как docker-compose down -v', }; const localCompose = [ "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:', ]; const practiceArticle = createRevision( { slug: 'editorial-2020-01-practice-docker-local', title: 'Локальная разработка в Docker: фиксируем окружение вместо «у меня работает»', categories: ['Docker', 'Frontend', 'Практика'], cover: '/assets/editorial/2020/docker-local-environment-2020.svg', excerpt: 'Контейнер не отменяет различия между кодом, томами, сетью и переменными. Собираем короткий локальный контракт, который можно повторить на чистой машине.', readingMinutes: 13, }, [ paragraph('Симптом знакомый: ветка запускается у автора, а на соседнем ноутбуке фронтенд не видит базу, порт уже занят или зависимости вдруг оказываются «не теми». Цена такой ошибки не сводится к десяти минутам настройки. Новый человек повторяет случайные команды из чата, команда спорит о версии Node вместо задачи, а дефект окружения маскирует реальную правку в коде. Контейнер сам по себе это не лечит: он может лишь спрятать ещё одну неописанную настройку.'), paragraph('Для локальной разработки я фиксирую не фразу «проект в Docker», а маленький контракт: откуда берётся образ, какой код попадает в контейнер, где живут генерируемые файлы и данные, по какому имени сервисы видят друг друга и какие значения получает процесс. Тогда проверка становится конкретной: docker-compose config показывает собранную конфигурацию, docker-compose ps показывает состояние, а один HTTP-запрос и лог связывают конфигурацию с поведением. Это учебный сценарий января 2020 года на Docker Engine 19.03 и отдельном CLI docker-compose; его границы не расширяются инструментами другой эпохи.'), heading('Окружение состоит из пяти наблюдаемых слоёв'), paragraph('Образ отвечает за базовую файловую систему и установленный runtime. Контейнер — за один запущенный процесс с его переменными и файловой системой на момент старта. Bind mount подставляет в контейнер каталог рабочей копии; named volume хранит данные под управлением Docker. Сеть даёт сервисам внутренние имена, а публикация порта связывает порт контейнера с портом хоста. Пока эти вещи называют словом «контейнер», причина сбоя остаётся неясной.'), paragraph('Например, код меняется на хосте, но web читает старую сборку. Это не обязательно ошибка watch-режима. Сначала нужно выяснить, есть ли bind mount к тому пути, откуда процесс читает исходники, и не перекрывает ли другой mount нужную директорию. Если браузер открывает localhost:3000, а сервис web пытается подключиться к localhost:5432, проблема не в публикации порта базы. Внутри контейнера localhost означает тот же контейнер; для базы нужен адрес db из локальной сети Compose.'), dataTable( 'Карта локального контракта: что наблюдаем до попытки «переустановить всё»', ['Наблюдение', 'Вероятная граница', 'Проверка', 'Действие'], [ ['Код изменён, но 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 в отдельном сценарии'], ], ), paragraph('Таблица не требует немедленно создавать отдельный контейнер на каждую библиотеку. Она задаёт порядок: сначала наблюдаем границу, затем меняем ровно её. Пересобрать образ после каждой ошибки удобно, но такой ритуал стирает доказательство. Если после пересборки проблема исчезла, всё ещё неизвестно, была ли причина в слое образа, в кеше, в volume или в том, что контейнеры стартовали в другом порядке.'), heading('Один Compose-файл вместо набора личных команд'), paragraph('Ниже минимальная конфигурация для фронтенда с PostgreSQL. Она намеренно не пытается описать production. В ней есть два сервиса, один порт для браузера, один внутренний адрес базы, bind mount для исходников и два named volume: один изолирует зависимости контейнера от каталога хоста, второй делает состояние базы явным. Номера образов зафиксированы как пример, чтобы обновление не происходило незаметно во время расследования.'), codeBlock(localCompose), paragraph('Поле version: 3.7 здесь не декоративно. В начале 2020 года проекты на Compose v1 обычно выбирали формат 2.x или 3.x и запускали его отдельной командой docker-compose. Поэтому в этом материале важен именно такой контракт: YAML описывает сервисы, а отдельный CLI собирает и запускает их. Другие инструменты и другой синтаксис не делают пример понятнее, пока их появление не стало частью практики проекта.'), paragraph('Поле build: . означает только, что образ web собирается из Dockerfile текущего проекта. Оно не гарантирует, что пакетный менеджер уже установил зависимости, а command: npm run dev не гарантирует готовность базы. Эти предположения нужно записать рядом с Dockerfile и скриптами проекта. В данной конфигурации DATABASE_URL указывает на db, потому что имя сервиса используется внутри локальной сети; порт PostgreSQL наружу не опубликован и не нужен браузеру.'), figure( '/assets/editorial/2020/docker-local-environment-2020.svg', 'Вертикальная схема локального Docker-окружения: рабочая копия на хосте монтируется в web, named volumes хранят зависимости и данные PostgreSQL, а web обращается к db по имени сервиса', 'Контракт локальной среды: код приходит с хоста через bind mount, состояние явно остаётся в named volumes, а сервисы разговаривают по внутреннему имени, не через localhost хоста.', ), heading('Mount — это договор о владельце файлов'), paragraph('Bind mount удобен потому, что сохраняет обычную работу редактора: файл меняется в рабочей копии, контейнер видит то же изменение по своему пути. Но он не копирует каталог в образ и не делает путь независимым от машины Docker daemon. Документация Docker прямо отделяет путь хоста daemon от пути контейнера и предупреждает, что mount скрывает прежнее содержимое целевой директории. Поэтому путь .:/srv/app надо воспринимать как часть интерфейса проекта, а не как безобидную строчку.'), paragraph('В примере второй mount на /srv/app/node_modules важен не из эстетики. Когда bind mount закрывает весь /srv/app, он может скрыть зависимости, которые были записаны в образе при сборке. Если вместо этого использовать host node_modules, двоичные дополнения и путь исполняемых файлов могут зависеть от операционной системы разработчика. Named volume отделяет эту производную среду от исходников. Он не делает зависимости вечными: при смене lock-файла их всё равно нужно обновить управляемой командой проекта.'), heading('Сеть проверяем с двух сторон'), paragraph('У ports: 3000:3000 есть ровно одна задача: дать браузеру на хосте путь к dev-server внутри web. Он не создаёт обратный путь от web к db. Для такого пути Compose создаёт внутреннюю сеть, в которой сервис доступен по имени. Это разделение помогает не публиковать базу только ради приложения и не исправлять строку подключения на localhost, когда ошибка происходит внутри контейнера.'), paragraph('Проверка должна записывать место, из которого сделан запрос. Если curl http://localhost:3000 на хосте возвращает HTML, это подтверждает публикацию порта. Если процесс в web не может открыть db:5432, нужно смотреть DNS-имя, сеть и логи базы, а не браузерный Network. Команда docker-compose exec web полезна именно как смена точки наблюдения: она запускает диагностику в файловой системе и сети web, а не в терминале хоста.'), heading('Короткий запуск и критерий готовности'), paragraph('Начинаю с docker-compose config. Эта команда разворачивает подстановки и показывает, какой YAML реально получил Compose; она не проверяет, что приложение успешно подключилось к базе. Затем запускаю docker-compose up -d, смотрю docker-compose ps и беру последние строки docker-compose logs --tail=50 web db. Только после этого открываю один заранее выбранный URL или выполняю один запрос из контейнера. Так у сбоя остаётся время, сервис и точка входа.'), paragraph('Статус «Up» означает, что контейнерный процесс жив, но не доказывает, что сервер слушает порт, миграции завершились или база принимает соединения. depends_on задаёт порядок старта, а готовность зависит от приложения. В учебной среде достаточно, чтобы web логировал успешное подключение и чтобы один запрос к странице прошёл после старта. В реальном проекте критерий стоит оформить отдельной health-проверкой или проверяемой командой, но не надо объявлять её существующей, если её нет в репозитории.'), orderedList([ 'Зафиксировать версию Docker Engine, версию docker-compose и один URL или команду, которые означают «локальная среда готова».', 'Открыть docker-compose config и сверить build, working_dir, mounts, переменные, сервисные имена и опубликованные порты с договором проекта.', 'Запустить docker-compose up -d, затем записать вывод docker-compose ps и короткий срез логов каждого зависимого сервиса.', 'Проверить вход с хоста отдельно от связи между контейнерами: браузер или curl проверяет порт web, диагностика внутри web проверяет имя db.', 'При сбое назвать один слой из таблицы, сделать обратимое изменение и повторить тот же запрос; не переустанавливать весь стек как первую реакцию.', 'Сохранить в README только команды и признаки, которые действительно повторены на чистом локальном запуске.', ]), heading('Итог: «одинаковая среда» должна быть проверяемой'), paragraph('Docker полезен локально, когда уменьшает количество личных предположений. Образ, container process, mount, volume, сеть, порт и переменная — разные владельцы состояния. У каждого есть свой наблюдаемый признак. Если описать их одной конфигурацией и проверить одним коротким маршрутом, следующий разработчик начинает не с поисков в истории чата, а с той же границы, что и автор изменения.'), paragraph('Этот пример не запускался в production-среде и не обещает переносимость между любыми ОС, версиями Docker Desktop или удалёнными daemon. Он задаёт более скромную цель: на выбранном проекте увидеть, откуда пришёл код, где остаются данные, каким именем доступна зависимость и какой запрос означает успешный старт. После такой фиксации ошибка «у меня работает» становится вопросом к конкретной строке конфигурации.'), ], [engine19, composeV1, legacyCompose, bindMounts, volumes, bridgeNetwork, startupOrder], ); const mechanismCompose = [ "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:', ]; const mechanismArticle = createRevision( { slug: 'editorial-2020-01-mechanism-docker-local', title: 'Почему контейнер не делает окружение одинаковым: файлы, сеть и переменные', categories: ['Docker', 'Инфраструктура', 'Разбор'], cover: '/assets/editorial/2020/docker-local-request-path-2020.svg', excerpt: 'Контейнер изолирует процесс, но не отменяет выбор путей, томов, сервисных адресов и переменных. Разбираем путь одного запроса и точки, где конфигурация расходится с ожиданием.', readingMinutes: 14, }, [ paragraph('Симптом выглядит противоречиво: контейнер api запущен, но не видит базу; переменная есть в терминале, но отсутствует в приложении; файл лежит рядом с Dockerfile, а процесс сообщает ENOENT. Цена — ложная уверенность, что Docker сделал среду одинаковой. На деле одинаковым стал только образ процесса, а границы между хостом, daemon, контейнером и сетью остались выбранными вручную.'), paragraph('Разбор начинаю с пути одного запроса, а не с команды «перезапусти compose». Браузер на хосте идёт в опубликованный порт API. API читает свои переменные и обращается к db по внутренней bridge-сети. PostgreSQL читает данные из named volume. В этой цепочке четыре разных адреса и владельца состояния. Если записать их отдельно, проверка становится короткой: выяснить, на какой стрелке потерялось значение, а не менять Dockerfile вслепую.'), heading('Четыре пространства, которые нельзя склеивать в голове'), paragraph('Первое пространство — рабочая копия на машине разработчика. Второе — машина, на которой запущен Docker daemon; в обычной локальной установке они часто совпадают, но это не обещание протокола. Третье — файловая система контейнера. Четвёртое — сеть контейнеров. Bind mount связывает первые два с третьим конкретным путём. Сервисное имя связывает процесс с четвёртым пространством. Переменные живут ещё в одном, процессном, слое: оболочка Compose может подставить строку в YAML, а затем только явно переданное значение попадёт в process.env.'), paragraph('Из этого следует практическое правило: слово localhost нельзя использовать без указания наблюдателя. В браузере на хосте localhost:3000 обычно ведёт к опубликованному порту контейнера. Внутри api тот же адрес ведёт обратно в api, а не к PostgreSQL. Для базы в Compose-стеке адресом является db. Если одна строка подключения используется и в host-script, и в контейнере, это не удобство, а два несовместимых контекста, которые надо назвать разными конфигурациями.'), dataTable( 'Один параметр — разные наблюдатели', ['Что видим', 'Где наблюдаем', 'Что это подтверждает', 'Чего не подтверждает'], [ ['localhost:3000 отвечает HTML', 'браузер или curl на хосте', 'порт web или api опубликован на хост', 'что другой контейнер может обратиться к localhost:3000'], ['db разрешается в адрес', 'процесс или shell внутри api', 'внутреннее имя сервиса доступно в его сети', 'что PostgreSQL уже принимает логин'], ['DATABASE_HOST=db есть в config', 'docker-compose config', 'Compose собрал ожидаемую строку конфигурации', 'что приложение использует эту переменную после старта'], ['Файл есть в рабочей копии', 'терминал хоста', 'исходник сохранён на хосте', 'что он смонтирован по пути, который читает процесс контейнера'], ], ), paragraph('Последняя колонка полезнее набора советов. Она не даёт сделать лишний вывод из удачной команды. Увидеть db в docker-compose config — не то же самое, что увидеть успешный TCP-connect. Увидеть контейнер в ps — не то же самое, что прочитать переменную его процессом. Каждый следующий шаг обязан идти в соседнее пространство, а не повторять предыдущий в другой форме.'), heading('Compose описывает связи, но не исполняет приложение'), paragraph('Конфигурация ниже оставляет все связи явными. У API есть рабочий путь, bind mount, команда запуска и две переменные для базы. У базы — образ, собственные переменные и named volume. В файле нет опубликованного порта PostgreSQL, потому что клиент базы — контейнер api, а не браузер на хосте. Это уменьшает число доступных адресов и делает ошибку адреса заметнее.'), codeBlock(mechanismCompose), paragraph('Строка DATABASE_HOST=db — не магический DNS. Compose создаёт локальную сеть для сервисов проекта, а имя сервиса используется в этой сети. Если API оказался запущен отдельной командой docker run или присоединён к другой сети, эта предпосылка исчезает. Поэтому в расследовании сначала сверяют, как именно был создан процесс: через тот же Compose-проект или обходным способом. Не стоит добавлять вручную IP адрес контейнера в .env: он относится к текущему запуску, а не к контракту сервиса.'), paragraph('Поле depends_on в этом примере выражает порядок: база создаётся раньше API. Оно не является доказательством готовности PostgreSQL обработать запрос именно в момент запуска Node. База может ещё выполнять инициализацию, миграция может не завершиться, а пользователь базы может оказаться неверным. Надёжная локальная проверка проще, чем обещание: API должен записать успешное подключение в лог либо один известный endpoint должен отработать после старта. Если этого признака нет, команда оставляет состояние «контейнер жив» и не называет его «среда готова».'), figure( '/assets/editorial/2020/docker-local-request-path-2020.svg', 'Вертикальная схема пути запроса: браузер на хосте обращается к опубликованному порту API, API получает DATABASE_HOST=db и соединяется с PostgreSQL по внутренней сети, данные базы остаются в named volume', 'Один запрос проходит через разные пространства: хостовый порт, процесс API, внутреннее имя сервиса и persistent volume базы. Стрелки нельзя заменить единым localhost.', ), heading('Подстановка в YAML и окружение процесса — не одно событие'), paragraph('Compose может взять значение из окружения запускающей оболочки или файла .env, чтобы собрать конфигурацию. Это ещё не значит, что приложение увидит его. Переменная становится доступной процессу контейнера, когда она объявлена через environment или подключена способом, который явно описан в конфигурации. Поэтому проверку делаю в два прохода. Сначала docker-compose config показывает итоговый YAML без догадки о подстановке. Затем диагностика внутри api выводит только безопасный факт — например, наличие DATABASE_HOST и его не-секретное значение.'), paragraph('Пароль не стоит проверять командой, которая печатает всё окружение в общий лог. Локальная среда не делает секреты безопасными от копирования в историю терминала или issue. Для учебного PostgreSQL-пароля в примере можно честно назвать его тестовым. Для настоящего проекта нужно определить отдельный способ передачи чувствительных значений, список разрешённых мест хранения и правило, какие поля не попадают в diagnostic output. Это не зрелая платформа секретов, а базовая средовая дисциплина: не путать «значение передали» с «значение показали всем».'), heading('Файлы движутся не так, как кажется'), paragraph('Если api стартует в /srv/api, а bind mount привязан к /app, код с хоста формально смонтирован, но процесс его не читает. Такое часто происходит после копирования compose-файла между проектами. Проверка проста: сопоставить working_dir, путь в command, путь в Dockerfile и целевой путь mount. Для Node-проекта нужно отдельно назвать судьбу node_modules: host-каталог, слой образа и named volume ведут себя по-разному.'), paragraph('Named volume для PostgreSQL решает другой вопрос: данные переживают пересоздание контейнера. Это полезно для обычного локального дня, но опасно для диагностики, если команда ожидает пустую базу после каждого up. Не нужно объявлять volume «кешем Docker» и удалять его при каждой ошибке. Правильнее записать, какие таблицы или миграции остаются в нём, и иметь отдельный явно разрушительный сценарий чистого старта. Тогда данные не исчезают случайно, а старое состояние не выдаётся за новую конфигурацию.'), heading('Маршрут диагностики без догадок'), paragraph('В январе 2020 я бы не начинал с оркестратора или сложной сети. Для двух локальных сервисов достаточно назвать контейнеры, тома, переменные и один запрос. Сила этого маршрута в том, что каждый шаг имеет ожидаемый артефакт. Если артефакт не получен, следующий шаг не выбирается по привычке: он вытекает из того пространства, в котором сигнал оборвался.'), orderedList([ 'Назвать один клиент и один ожидаемый ответ: браузер на хосте, Node-процесс в api или PostgreSQL; не писать просто «проверить localhost».', 'Собрать docker-compose config и сверить итоговые volumes, working_dir, environment, depends_on и сервисное имя базы.', 'После docker-compose up -d проверить состояние контейнеров и логи, не принимая статус Up за подтверждение готовности зависимости.', 'Изнутри api проверить только нужный слой: разрешение имени db, наличие безопасной переменной или доступность конкретного endpoint.', 'Если связь не прошла, изменить один адрес, mount или переменную, повторить тот же запрос и записать, какая стрелка схемы изменилась.', 'Отдельно договориться о чистом запуске для volume: когда его разрешено удалить и чем подтверждается пустое состояние.', ]), heading('Итог: контейнер изолирует процесс, а не договорённости'), paragraph('Docker делает локальный процесс воспроизводимее, но не выбирает за проект, какой путь считать рабочим, где хранить данные и как один сервис находит другой. Эти решения остаются в Compose-файле, Dockerfile, переменных и запуске. Самая полезная привычка — проговаривать наблюдателя: хост, daemon, контейнер API или сеть сервисов. Тогда localhost, путь файла и значение переменной перестают быть двусмысленными.'), paragraph('Пример не запускался с Docker daemon и не является production-конфигурацией. Он не измеряет задержки сети и не проверяет реальную базу. Его назначение скромнее: показать, какие факты надо получить до исправления. Если команда повторит маршрут на своём репозитории, она получит не обещание «в Docker одинаково», а точную карту выбранных границ и место, где они расходятся.'), ], [engine19, composeV1, legacyCompose, bindMounts, volumes, bridgeNetwork, composeEnvironment, startupOrder], ); const cleanStartCompose = [ "version: '3.7'", '', 'services:', ' web:', ' build: .', ' command: npm run dev', ' ports:', " - '3000:3000'", ' environment:', ' DATABASE_URL: postgres://app:app@db:5432/app', ' volumes:', ' - .:/srv/app', '', ' 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:', ]; const fieldArticle = createRevision( { slug: 'editorial-2020-01-field-docker-local', title: 'Чистый запуск Docker: как отличить ошибку конфигурации от следа старого тома', categories: ['Docker', 'Отладка', 'Практика'], cover: '/assets/editorial/2020/docker-local-clean-start-2020.svg', excerpt: 'Повторный docker-compose up не делает базу пустой и не отменяет bind mounts. Собираем безопасный сценарий чистого запуска, который отделяет новую конфигурацию от старого состояния.', readingMinutes: 13, }, [ paragraph('Симптом неприятный: после изменения миграции локальная база ведёт себя по-старому, а на новом ноутбуке сценарий не повторяется. Кажется, что Docker игнорирует compose-файл, поэтому хочется удалить все контейнеры и выполнить глобальный prune. Цена такого действия выше самой ошибки: можно потерять нужные локальные данные, не понять, какой слой был виноват, и оставить в README разрушительную команду без границ.'), paragraph('Чистый запуск нужен не как ритуал, а как контрольный эксперимент. Он отвечает на один вопрос: ошибка воспроизводится на конфигурации без выбранного старого состояния или исчезает вместе с конкретным volume, образом либо bind mount. Для этого сначала фиксирую проект, сервисы и данные, которые разрешено удалить, затем собираю итоговый Compose YAML, запускаю стек и проверяю один известный маршрут. В январе 2020 это означает отдельный CLI docker-compose и Compose-файл 3.7; другой синтаксис не подменяет исторический пример.'), heading('Почему повторный up не равен чистой среде'), paragraph('Контейнер можно пересоздать, а named volume останется. В PostgreSQL это обычно означает, что каталог данных с таблицами, пользователями и историей миграций пережил контейнер. Bind mount ведёт себя иначе: он каждый раз показывает текущие файлы рабочей копии. Образ содержит зафиксированные при сборке слои, но уже созданный контейнер может продолжать работать на старом образе, пока его не пересоздали. Эти три типа состояния нельзя удалять одной командой «на всякий случай».'), paragraph('Сначала полезно назвать ожидаемую чистоту. Нужна пустая база? Тогда артефакт — отсутствие старых таблиц до миграции и понятный лог их применения. Нужен чистый frontend build? Тогда артефакт — версия образа или пересозданный контейнер, а не удалённый volume PostgreSQL. Нужны свежие исходники? Тогда проверяется bind mount и рабочая копия. Такая формулировка защищает от ложного успеха: после удаления всех данных приложение может подняться, но исходная ошибка в неправильной строке DATABASE_URL останется нерасследованной.'), dataTable( 'Классификация старого состояния перед чистым запуском', ['Слой', 'Какой след может остаться', 'Безопасная проверка', 'Разрешённое действие'], [ ['Контейнер web', 'старый процесс или старый command', 'сверить образ, command и время создания через docker-compose ps и inspect', 'пересоздать только сервис web после фиксации конфигурации'], ['Named volume postgres_data', 'схема, данные, журнал миграций PostgreSQL', 'назвать volume и ожидаемый признак пустой базы', 'удалить только volume данного локального проекта после явного подтверждения'], ['Bind mount рабочей копии', 'не старый снимок, а текущие файлы хоста', 'сопоставить путь mount с working_dir и изменить контрольный файл', 'исправить путь или рабочую копию; удаление volume не поможет'], ['Образ web', 'слои зависимостей и код, скопированный при build', 'проверить, был ли образ пересобран после изменения Dockerfile или lock-файла', 'пересобрать конкретный образ, не стирая данные базы'], ], ), paragraph('В таблице намеренно нет команды глобальной очистки Docker. Она слишком широка для локального расследования: может затронуть образы, volumes и сети других проектов. Локальный эксперимент должен иметь имя Compose-проекта и ограниченный набор сервисов. Если человек не может назвать volume, который удаляет, операция ещё не готова к запуску.'), heading('Сначала смотрим фактический проект, потом удаляем состояние'), paragraph('Перед разрушительной веткой запускаю docker-compose config --services и docker-compose config. Первая команда помогает увидеть, какой набор сервисов объявлен. Вторая показывает, не подставилась ли неожиданная переменная и действительно ли база пишет в postgres_data. Затем сохраняю короткий лог docker-compose logs --tail=50 web db и состояние docker-compose ps. Это снимок до эксперимента: без него невозможно отличить «состояние было старым» от «стек вообще не поднимался».'), paragraph('В Compose-файле ниже named volume назван явно. Это важно для разговора о чистом старте: команда обсуждает не абстрактный «docker cache», а конкретный каталог данных PostgreSQL. Bind mount для исходников оставлен отдельно и не исчезает при очистке volume. Если проект использует реальные локальные данные, до такой операции нужен экспорт или иной согласованный способ восстановления; учебный пароль и база в примере не являются разрешением удалять чужую рабочую базу.'), codeBlock(cleanStartCompose), paragraph('Историческая команда docker-compose down -v удаляет ресурсы, связанные с данным Compose-проектом, включая объявленные named volumes, когда флаг -v действительно передан. Это полезно только после проверки имени проекта и списка сервисов. Она не заменяет понимание того, что именно удаляет, и не должна записываться в README без слова «локально» и без предупреждения о данных. В этом материале сохранён синтаксис эпохи Compose v1, чтобы команда и формат файла не расходились.'), figure( '/assets/editorial/2020/docker-local-clean-start-2020.svg', 'Вертикальная схема чистого локального запуска: сначала фиксируются Compose config и логи, затем отдельно выбираются container, image или named volume, после чего запускается один проверяемый маршрут', 'Чистый запуск — это не глобальная уборка: он отделяет образ, контейнер, bind mount и named volume, чтобы удалить только подтвержденный источник старого состояния.', ), heading('Один контрольный маршрут вместо «вроде заработало»'), paragraph('После очистки не достаточно увидеть зелёный статус Up. Выбираю один маршрут, который точно зависит от обновлённого слоя. Для базы это может быть запуск миграции с ожидаемым логом и запрос к таблице. Для frontend — один URL, в ответе которого видна версия сборки или изменённая строка. Для переменной — безопасная диагностическая строка, подтверждающая, что приложение прочитало нужный режим. Маршрут должен быть коротким и повторяемым до и после эксперимента.'), paragraph('Если после удаления postgres_data ошибка исчезла, вывод тоже ограничен: старое состояние базы было частью условия. Это ещё не доказывает, что миграция написана правильно или что production переживёт обновление. Если ошибка осталась, volume исключён, и следующий кандидат — адрес сервиса, переменная, образ или код. Такая развилка экономит время: вместо нового полного переустановления команда переносит внимание только на слой, который ещё способен объяснить симптом.'), heading('Чистота должна быть обратимой и документированной'), paragraph('В локальной команде полезно держать два разных маршрута: обычный docker-compose up -d для продолжения работы и явно разрушительный clean-start для тестовой базы. У разрушительного маршрута должны быть входные условия: проект локальный, данные не нужны или сохранены, имя Compose-проекта проверено, список volumes известен. После него должны быть выходные условия: база создана заново, миграции прошли, выбранный endpoint отвечает. Без этих границ команда учится стирать состояние, а не отлаживать конфигурацию.'), paragraph('Образ тоже не надо смешивать с volume. Если Dockerfile или lock-файл изменились, можно пересобрать только web и затем проверить, использует ли новый контейнер этот образ. Если изменились исходники под bind mount, сборка может вообще не быть причиной. Если поменялась база, named volume может быть именно тем, что надо сохранить для обычной работы. Хороший сценарий чистого запуска показывает эти различия прямо в имени команды и в ожидаемом результате.'), heading('Маршрут чистого эксперимента'), orderedList([ 'Назвать симптом и слой, который должен быть чистым: образ, контейнер, bind mount или named volume; не начинать с удаления всего Docker.', 'Зафиксировать имя локального Compose-проекта, вывод docker-compose config --services, итоговый config, ps и короткие логи до изменения.', 'Проверить, какие данные затрагивает выбранный volume; сохранить нужный локальный дамп либо остановиться, если безопасность данных не доказана.', 'Выполнить ограниченное действие: пересобрать web, пересоздать один контейнер или для учебной базы осознанно выполнить docker-compose down -v в нужном проекте.', 'Запустить тот же стек и пройти один контрольный маршрут: миграция, безопасный запрос или endpoint с заранее описанным результатом.', 'Записать вывод: какой слой исключён или подтверждён, какие данные были удалены и какой следующий эксперимент нужен, если симптом остался.', ]), heading('Итог: чистый запуск — это доказательство, а не кнопка'), paragraph('В Docker локальное состояние живёт в нескольких местах. Контейнеры, образы, bind mounts и named volumes переживают разные операции. Поэтому чистый запуск ценен только тогда, когда заранее определено, какой след удаляется и какая проверка должна измениться. Такая дисциплина оставляет данные в безопасности и превращает странный локальный дефект в последовательность наблюдений.'), paragraph('Эта статья не запускала команды с Docker daemon и не удаляла volumes рабочего проекта. Примеры показывают структуру безопасного эксперимента, а не подтверждённый прогон production-среды. Перед использованием в конкретном репозитории нужно подставить реальные сервисы, путь данных, команду миграции и правило резервного копирования. Если эти четыре вещи не названы, чистый запуск ещё нельзя считать безопасным.'), ], [engine19, composeV1, legacyCompose, volumes, bindMounts, composeDown, startupOrder], ); export const revisions = [practiceArticle, mechanismArticle, fieldArticle] .map(({ proseLength, ...revision }) => revision); const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isDirectRun) { if (process.argv.includes('--print-revisions')) { process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); } else { process.stderr.write('Usage: node web/scripts/upgrade-2020-01.mjs --print-revisions\n'); process.exitCode = 1; } }