Первый Dockerfile: От bash-скрипта к контейнеру

Курс закладывает фундамент контейнеризации, переводя навыки системного администрирования в декларативный формат Docker. Вы научитесь превращать последовательность команд терминала в воспроизводимый образ приложения.

Аналогия скрипта и образа: Инструкции FROM и RUN как фундамент файловой системы

Аналогия скрипта и образа: Инструкции FROM и RUN как фундамент файловой системы

Любой системный администратор рано или поздно сталкивается с сервером, который настраивался годами. Никто точно не знает, какие пакеты там установлены, какие версии библиотек конфликтуют друг с другом и почему приложение работает только в этой конкретной среде. Попытка перенести проект на новую машину начинается с написания длинного bash-скрипта, который должен воспроизвести окружение. Скрипт ломается на середине из-за отсутствия нужного репозитория, оставляет систему в полунастроенном состоянии и требует ручного вмешательства. Контейнеризация решает эту проблему, заменяя императивный хаос на предсказуемый рецепт сборки, где каждая команда фиксируется в неизменяемом виде.

От bash-скрипта к декларативному рецепту

Чтобы понять природу Dockerfile, полезно посмотреть на него как на эволюцию обычного shell-скрипта. Когда вы пишете скрипт для подготовки сервера, вы мыслите последовательностью действий: «обнови списки пакетов, скачай утилиту, создай директорию, поменяй права».

Рассмотрим типичный bash-скрипт для установки веб-сервера:

#!/bin/bash
# Предполагается, что мы запускаем это на Ubuntu
apt-get update
apt-get install -y nginx
mkdir -p /var/www/html/myapp
echo "Hello World" > /var/www/html/myapp/index.html

У этого подхода есть фундаментальный изъян: скрипт зависит от исходного состояния машины. Если на сервере уже стоит другая версия Nginx, скрипт может повести себя непредсказуемо. Если запустить его на CentOS, он сразу завершится с ошибкой, так как там нет пакетного менеджера apt-get.

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

FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y nginx
RUN mkdir -p /var/www/html/myapp
RUN echo "Hello World" > /var/www/html/myapp/index.html

На первый взгляд, разница лишь в синтаксисе — добавились заглавные слова слева. Но технически разница колоссальна. Bash-скрипт мутирует существующую операционную систему. Dockerfile создаёт новую, изолированную файловую систему с нуля.

Инструкция FROM: Точка отсчёта

Каждый Dockerfile обязан начинаться с инструкции FROM (единственное исключение — парсерные директивы и глобальные аргументы, но об этом позже). FROM определяет базовый образ, который станет фундаментом для всех последующих действий.

Когда вы пишете FROM ubuntu:22.04, вы не скачиваете полноценную операционную систему с ядром Linux, загрузчиком GRUB и драйверами видеокарт. Контейнеры переиспользуют ядро хост-системы. Базовый образ содержит только userland — иерархию директорий (/bin, /etc, /usr), стандартные системные библиотеки (например, glibc) и базовые утилиты (ls, bash, apt).

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

  1. Полноценные дистрибутивы (ubuntu, debian, centos). Весят от 50 до 150 мегабайт. Содержат привычные инструменты отладки (curl, bash, пакетные менеджеры). Идеальны для этапа разработки и прототипирования.
  2. Минималистичные дистрибутивы (alpine). Весят около 5 мегабайт. Основаны на библиотеке musl libc вместо glibc и используют busybox вместо стандартных GNU-утилит. Отличный выбор для production, но требует осторожности: программы, скомпилированные под glibc (например, некоторые сборки Python-библиотек или бинарники C++), могут не запуститься или потребовать перекомпиляции.
  3. Абсолютный ноль (scratch). Специальное зарезервированное имя. Инструкция FROM scratch означает пустую файловую систему. Этот подход используется для запуска статически скомпилированных бинарных файлов (например, написанных на Go или Rust), которым вообще не нужны системные библиотеки.

Инструкция RUN: Исполнитель команд

Если FROM даёт нам чистый лист, то RUN — это ручка, которой мы пишем. Инструкция RUN выполняет любую команду внутри файловой системы контейнера на этапе его сборки.

Важно понимать, что RUN работает только во время сборки образа (команда docker build). Всё, что делает RUN, навсегда «запекается» в образ. Эта инструкция не имеет никакого отношения к тому, что будет происходить, когда контейнер запустится.

Существует две формы записи RUN:

Shell-форма:

RUN apt-get install -y curl

В этом случае Docker неявно оборачивает вашу команду в вызов командной оболочки. Фактически выполняется /bin/sh -c "apt-get install -y curl". Это позволяет использовать переменные окружения, перенаправление потоков (>) и логические операторы (&&, ||).

Exec-форма (JSON массив):

RUN ["apt-get", "install", "-y", "curl"]

Здесь Docker вызывает исполняемый файл напрямую, минуя shell. Это защищает от неожиданностей, связанных с особенностями /bin/sh, и немного экономит ресурсы. Однако в такой записи не будут работать символы подстановки и конвейеры, так как их обрабатывает именно shell. RUN ["echo", "$HOME"] не выведет путь к домашней директории, а напечатает буквальную строку "$HOME".

Для большинства задач по настройке файловой системы (установка пакетов, создание папок) используется именно shell-форма из-за её гибкости.

Анатомия слоёв: Почему количество RUN имеет значение

Возвращаясь к нашему примеру с установкой Nginx, мы написали четыре инструкции RUN подряд. В мире bash-скриптов это нормальная практика. В мире Docker — это грубая архитектурная ошибка, которая выдаёт новичка.

Каждая инструкция RUN (а также COPY и ADD) создаёт новый слой в файловой системе образа. Слои в Docker работают по принципу Copy-on-Write (копирование при записи). Если на слое 1 существует файл, а на слое 2 вы его изменяете, Docker не перезаписывает оригинал. Он копирует файл на слой 2 и вносит изменения там. При чтении файловой системы вы всегда видите самую верхнюю версию файла.

Посмотрим, что произойдёт, если мы скачаем архив, распакуем его и удалим исходный файл в разных инструкциях RUN:

FROM ubuntu:22.04
# Слой 1: скачиваем архив размером 100 МБ
RUN curl -O https://example.com/huge-archive.tar.gz
# Слой 2: распаковываем (появляется ещё 300 МБ данных)
RUN tar -xzf huge-archive.tar.gz
# Слой 3: удаляем исходный архив
RUN rm huge-archive.tar.gz

Логика подсказывает, что финальный образ должен увеличиться на 300 МБ (только распакованные данные). Но на самом деле он вырастет на 400 МБ. Архив весом 100 МБ навсегда остался в первом слое. Команда rm в третьем слое лишь пометила этот файл как удалённый (создала так называемый whiteout-файл), поэтому вы не увидите его при запуске контейнера. Но физически архив всё ещё занимает место на диске и будет скачиваться каждый раз при передаче образа по сети.

Чтобы файловая система оставалась компактной, связанные команды необходимо объединять в один слой с помощью логического оператора &&:

FROM ubuntu:22.04
# Всё происходит в рамках одного слоя
RUN curl -O https://example.com/huge-archive.tar.gz && \
    tar -xzf huge-archive.tar.gz && \
    rm huge-archive.tar.gz

В этом случае архив скачивается, распаковывается и удаляется во время формирования одного-единственного слоя. Когда Docker фиксирует этот слой, исходного архива в нём уже нет, и лишние 100 МБ не попадают в итоговый образ.

Ловушка кэширования apt-get

Понимание слоёв ведёт нас к ещё одной критической концепции — кэшированию при сборке. Когда вы запускаете docker build, Docker анализирует каждую инструкцию. Если инструкция и её контекст не изменились с прошлой сборки, Docker не выполняет её заново, а берёт готовый слой из кэша. Это делает сборку невероятно быстрой.

Но кэш может сыграть злую шутку. Представьте такой Dockerfile:

FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y python3

Вы собираете образ сегодня. Docker выполняет apt-get update, скачивает свежие списки пакетов и сохраняет этот слой. Затем устанавливает Python. Через два месяца вам нужно добавить в образ утилиту git. Вы меняете Dockerfile:

FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y python3 git

Вы запускаете сборку. Docker видит инструкцию RUN apt-get update. Строка не изменилась? Нет. Значит, можно использовать кэш двухмесячной давности! Docker берёт старый слой со старыми списками пакетов и переходит к следующей инструкции. На этапе RUN apt-get install -y python3 git пакетный менеджер пытается скачать git по ссылкам из старого кэша. Но на серверах Ubuntu эти версии уже давно удалены или перемещены. Сборка падает с ошибкой 404 Not Found.

Именно поэтому золотое правило написания Dockerfile гласит: всегда объединяйте обновление списков пакетов и установку в одну инструкцию RUN.

Правильный подход выглядит так:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
    python3 \
    git \
    && rm -rf /var/lib/apt/lists/*

Если вы добавите новый пакет в этот список, текст инструкции изменится. Docker инвалидирует кэш для всего этого слоя, честно выполнит apt-get update и скачает актуальные пакеты. Обратите внимание на последнюю строку rm -rf /var/lib/apt/lists/* — это ещё одна best practice. Она очищает кэш самого пакетного менеджера внутри слоя, экономя 20-40 мегабайт в финальном образе.

Превращение скрипта в Dockerfile требует смены парадигмы. Мы больше не просто выполняем команды одну за другой. Мы проектируем неизменяемую файловую систему, где базовый образ задаёт контекст, а каждая инструкция оставляет перманентный след в истории контейнера. Построив фундамент с помощью правильного выбора FROM и грамотной группировки RUN, мы получаем готовую операционную среду. Однако пока эта среда пуста — в ней есть интерпретаторы и утилиты, но нет нашего собственного кода.

Перенос локальных ресурсов: Команды COPY и ADD для наполнения контейнера данными

Перенос локальных ресурсов: Команды COPY и ADD для наполнения контейнера данными

В консоли появляется строка: Sending build context to Docker daemon 4.52GB. Сборка простейшего образа с веб-сервером внезапно замирает на несколько минут, а кулеры компьютера начинают шуметь. Разработчик всего лишь хотел упаковать пару HTML-файлов, но вместо этого Docker начал копировать внутрь базы данных, логи, виртуальное окружение сотен зависимостей и скрытые папки системы контроля версий. Эта ситуация — прямое следствие непонимания того, как именно файлы с жесткого диска хоста попадают внутрь файловой системы будущего контейнера.

Архитектура сборки и граница контекста

Чтобы понять поведение команд переноса файлов, необходимо посмотреть на архитектуру Docker. Он работает по клиент-серверной модели. Когда в терминале вводится команда docker build -t my-app ., взаимодействуют два разных компонента:

  1. Docker CLI (клиент) — утилита, принимающая команды в терминале.
  2. Docker Daemon (сервер/движок) — фоновый процесс, который реально выполняет сборку, скачивает базовые образы и создает слои.

Точка . в конце команды сборки указывает клиенту путь к контексту сборки (build context). Контекст — это директория на локальной машине, содержимое которой клиент рекурсивно архивирует и отправляет демону по API. Демон может находиться на той же машине, а может — на удаленном сервере в другом полушарии.

Именно из-за этой архитектурной особенности возникает популярная ошибка новичков: попытка скопировать файл из родительской директории. Инструкция COPY ../config.json /app/ неминуемо завершится ошибкой. Демон, выполняющий сборку, работает исключительно с тем архивом, который ему прислал клиент. Он изолирован от файловой системы хоста и физически не может выйти за пределы распакованного у себя контекста.

Санитар файловой системы: .dockerignore

Если клиент отправляет демону всё содержимое директории контекста, возникает две проблемы:

  • Утечка данных: в образ могут попасть файлы .env с паролями к продакшн-базам, ключи SSH или локальные конфигурации.
  • Деградация производительности: передача гигабайтов директорий node_modules, vendor или .git демону занимает время, даже если они находятся на одном SSD.

Для фильтрации контекста до того, как он будет отправлен демону, используется файл .dockerignore. Он помещается в корень контекста сборки.

Синтаксис файла опирается на паттерны сопоставления (globbing):

  • node_modules/ — исключает директорию целиком.
  • *.log — исключает все файлы с таким расширением в корне.
  • **/*.log — исключает файлы с расширением во всех вложенных директориях.
  • !README.md — правило-исключение. Если ранее была проигнорирована целая папка, знак восклицания заставит Docker всё же включить конкретный файл из неё в контекст.

Важное правило безопасности: в .dockerignore всегда следует добавлять сам файл .dockerignore и Dockerfile. Они не нужны для работы приложения внутри контейнера, и их отсутствие в итоговом образе уменьшает поверхность потенциальной атаки, скрывая детали инфраструктуры от злоумышленника.

Инструкция COPY: Предсказуемый перенос

Инструкция COPY — это основной, самый надежный и предсказуемый инструмент для переноса файлов из контекста сборки в файловую систему образа.

Базовый синтаксис выглядит так: COPY <src> <dest>

  • <src> (источник) — путь к файлу или директории внутри контекста сборки.
  • <dest> (назначение) — абсолютный путь внутри образа (или относительный, если задана рабочая директория, что будет разобрано в последующих главах).

Нюансы путей и слешей

Поведение COPY сильно зависит от наличия закрывающего слеша / в путях. Разберем граничные случаи на примере директории src, внутри которой лежат файлы main.py и utils.py.

  1. Копирование содержимого директории: COPY src /app Docker возьмет содержимое папки src и положит его в папку /app. Результат внутри контейнера: /app/main.py и /app/utils.py. Сама папка src не создается.

  2. Копирование самой директории: Чтобы получить внутри контейнера структуру /app/src/main.py, необходимо явно указать целевую поддиректорию: COPY src /app/src

  3. Множественные источники: Инструкция поддерживает указание нескольких файлов. В этом случае путь назначения строго обязан заканчиваться слешем, иначе сборка упадет с ошибкой. COPY file1.txt file2.txt /app/data/

Если целевой директории /app/data/ на момент выполнения COPY не существует в файловой системе образа, Docker автоматически создаст её, а также все недостающие родительские директории (аналог mkdir -p).

Проблема прав доступа и флаг --chown

По умолчанию все файлы и директории, перенесенные через COPY, получают владельца root (UID 0) и группу root (GID 0). Для обеспечения безопасности приложения в production-среде процессы в контейнере должны запускаться от имени непривилегированного пользователя.

Если приложение попытается записать данные в директорию, скопированную по умолчанию, оно получит ошибку Permission denied.

Наивное решение выглядит так:

COPY src/ /var/www/html/
RUN chown -R www-data:www-data /var/www/html/

С точки зрения финального результата права будут верными. Но с точки зрения архитектуры слоёв (Copy-on-Write) произойдет катастрофа. Инструкция COPY создаст слой с файлами исходного кода. Следующая инструкция RUN chown изменит метаданные этих файлов. Механизм CoW скопирует все измененные файлы из предыдущего слоя в новый. В результате исходный код будет сохранен на диске дважды, раздувая размер образа.

Решение — использование флага --chown непосредственно в инструкции копирования: COPY --chown=www-data:www-data src/ /var/www/html/

В этом случае файлы сразу помещаются в слой с нужными правами и владельцем. Дублирования данных не происходит. Флаг принимает как символьные имена пользователей, так и числовые идентификаторы (UID:GID), например --chown=1000:1000.

Инструкция ADD: Магия, которую стоит контролировать

Исторически первой командой для переноса файлов была ADD. Она обладает тем же базовым синтаксисом, что и COPY, но включает в себя скрытую «магию» — дополнительное поведение, которое активируется в зависимости от типа источника.

У ADD есть две уникальные функции:

1. Автоматическая распаковка локальных архивов Если в качестве <src> передан локальный архив в распознаваемом формате (tar, gzip, bzip2, xz), ADD не просто скопирует файл, а распакует его содержимое в директорию <dest>. ADD backup.tar.gz /data/ Внутри образа по пути /data/ окажутся извлеченные файлы, а самого архива backup.tar.gz там не будет. Это удобно для переноса больших дампов или предкомпилированных rootfs, но создает непредсказуемость: разработчик, читающий Dockerfile, не всегда может по названию файла понять, скопируется ли он как единый файл или развернется в дерево директорий.

2. Загрузка файлов по URL ADD умеет принимать в качестве источника веб-ссылки: ADD https://example.com/big-dataset.json /app/data/ Docker скачает файл по сети и положит его в образ. Однако у этого механизма есть критический недостаток. Загруженный файл останется в слое навсегда. Если это был архив, который нужно распаковать, а затем удалить, сделать это эффективно не выйдет.

Сравним два подхода к скачиванию архива:

Плохой подход (через ADD):

ADD https://example.com/tool.tar.gz /opt/
RUN tar -xzf /opt/tool.tar.gz -C /opt/ && rm /opt/tool.tar.gz

Здесь ADD скачивает архив (создается слой 1). Затем RUN распаковывает его и удаляет оригинал (создается слой 2). Архив физически удален в слое 2, но навсегда остался лежать в слое 1, увеличивая вес образа.

Правильный подход (через RUN и curl):

RUN curl -sSL https://example.com/tool.tar.gz -o /opt/tool.tar.gz \
    && tar -xzf /opt/tool.tar.gz -C /opt/ \
    && rm /opt/tool.tar.gz

Здесь скачивание, распаковка и удаление происходят в рамках одной инструкции RUN. В итоговый слой попадают только распакованные бинарные файлы. Временный архив tool.tar.gz существует только во время выполнения команды и не сохраняется в истории слоёв.

Сравнение COPY и ADD

Характеристика COPY ADD
Копирование локальных файлов Да Да
Поддержка флага --chown Да Да
Скачивание по URL Нет Да
Авто-распаковка архивов Нет Да (только локальных)
Предсказуемость поведения Высокая (что указано, то и скопировано) Низкая (зависит от формата файла)

Официальные рекомендации (Best Practices) от разработчиков Docker однозначны: всегда используйте COPY для переноса файлов из контекста. Команду ADD следует применять исключительно в тех редких случаях, когда вам действительно нужна автоматическая распаковка локального tar-архива. Для скачивания файлов из интернета всегда предпочтительнее использовать RUN в связке с curl или wget.

Понимание границы между локальной машиной и демоном, грамотная фильтрация контекста и явное управление правами доступа при копировании — это база, без которой невозможно собрать безопасный образ. Когда нужные файлы оказались внутри файловой системы образа, возникает следующая задача: объяснить контейнеру, какой именно процесс он должен запустить при старте.

Жизненный цикл процесса: Различия между CMD и ENTRYPOINT для запуска приложения

Жизненный цикл процесса: Различия между CMD и ENTRYPOINT для запуска приложения

Главная ошибка новичков — воспринимать Docker-контейнер как легковесную виртуальную машину, которая запускается, ждет команд и работает в фоновом режиме бесконечно. В реальности контейнер — это просто красивая обертка вокруг одного конкретного процесса.

Как только этот процесс завершает свою работу, контейнер мгновенно умирает. Файловая система собрана, ресурсы скопированы, но без запущенного приложения контейнер не имеет смысла. За то, какой именно процесс станет сердцем контейнера, отвечают две инструкции: CMD и ENTRYPOINT.

Иллюзия виртуальной машины и правило PID 1

В любой Linux-системе первый запускаемый процесс получает идентификатор PID=1PID = 1. Он становится прародителем всех остальных процессов и отвечает за их корректное завершение.

Внутри контейнера работает то же правило, но с жестким ограничением: процесс с PID=1PID = 1 — это и есть сам контейнер. Если вы запустили скрипт, который отработал за 2 секунды и вывел текст в консоль, контейнер проживет ровно 2 секунды.

Именно поэтому форма записи команд для запуска критически важна. В первой главе мы касались разницы между shell и exec формами для инструкции RUN. Для запуска приложения эта разница становится вопросом жизни и смерти процесса.

Рассмотрим классический антипаттерн (shell-форма): CMD python /app/server.py

Под капотом Docker обернет эту команду в оболочку: /bin/sh -c "python /app/server.py". Что произойдет с процессами?

  1. Оболочка /bin/sh получит PID=1PID = 1.
  2. Ваш Python-сервер получит PID=2PID = 2.

Когда вы попытаетесь остановить контейнер командой docker stop, демон отправит сигнал мягкого завершения (SIGTERM) процессу с PID=1PID = 1. Но стандартный /bin/sh игнорирует этот сигнал и не передает его дочерним процессам. В результате контейнер «зависнет» на 10 секунд, после чего Docker принудительно «убьет» его сигналом SIGKILL. Приложение не успеет сохранить данные, закрыть соединения с базой и корректно завершить работу.

Правильный подход — использовать exec-форму (JSON-массив): CMD ["python", "/app/server.py"]

В этом случае оболочка не создается. Процесс python напрямую получает PID=1PID = 1, корректно перехватывает системные сигналы и безопасно завершает работу.

CMD: Инструкция по умолчанию

Инструкция CMD задает команду и аргументы, которые выполнятся при старте контейнера по умолчанию. Ключевое слово здесь — «по умолчанию», потому что CMD создана для того, чтобы ее было легко переопределить.

Допустим, мы упаковали Nginx. В конце Dockerfile мы пишем: CMD ["nginx", "-g", "daemon off;"]

Если пользователь просто запустит образ (docker run my-nginx), запустится веб-сервер. Но если пользователю нужно зайти в контейнер для отладки, он передаст свою команду в конце: docker run -it my-nginx bash

Слово bash, переданное через CLI, полностью уничтожает и заменяет массив, указанный в CMD. Веб-сервер не запустится вообще, вместо него с PID=1PID = 1 стартует интерактивная оболочка.

ENTRYPOINT: Несгибаемый фундамент

В отличие от CMD, инструкция ENTRYPOINT предназначена для фиксации исполняемого файла, который должен запуститься в любом случае.

Представьте, что вы упаковываете не сервер, а консольную утилиту, например, ping. ENTRYPOINT ["ping"]

Если пользователь запустит docker run my-ping 8.8.8.8, переданный аргумент 8.8.8.8 не заменит ping. Он добавится в конец массива ENTRYPOINT. Контейнер выполнит команду ping 8.8.8.8.

Попытка переопределить ENTRYPOINT обычным способом (docker run my-ping bash) приведет к ошибке: контейнер попытается выполнить ping bash, так как bash будет воспринят как адрес для пинга. Переопределить ENTRYPOINT можно только явно, передав специальный флаг --entrypoint при запуске, что делается крайне редко и только для глубокой отладки.

Идеальный паттерн: Симбиоз ENTRYPOINT и CMD

Настоящая магия Dockerfile раскрывается, когда эти две инструкции работают в паре.

Архитектурный стандарт индустрии гласит:

  1. ENTRYPOINT содержит неизменяемый исполняемый файл (и, возможно, базовые флаги).
  2. CMD содержит параметры по умолчанию, которые пользователь скорее всего захочет изменить.

Рассмотрим Dockerfile для утилиты скачивания файлов wget:

# Устанавливаем фундамент (программу)
ENTRYPOINT ["wget", "-O", "/downloads/file"]

# Устанавливаем аргумент по умолчанию (URL)
CMD ["http://example.com/default.zip"]

Docker берет массив из ENTRYPOINT и приклеивает к нему массив из CMD. В результате стартует команда: wget -O /downloads/file http://example.com/default.zip

Что произойдет, если пользователь передаст свой URL при запуске? docker run my-wget http://mysite.com/new.zip

Аргумент из CLI полностью заменит инструкцию CMD, но ENTRYPOINT останется на месте. Итоговая команда внутри контейнера изменится на: wget -O /downloads/file http://mysite.com/new.zip

Использование связки ENTRYPOINT и CMD превращает ваш Docker-образ из простого хранилища файлов в полноценный, удобный CLI-инструмент с предсказуемым поведением. Вы гарантируете, что всегда будет запущено нужное приложение, но оставляете пользователю свободу гибко настраивать его параметры на лету.

Чтобы приложение корректно находило свои конфигурационные файлы, а порты были доступны снаружи, процессу требуется правильно настроенное окружение. О том, как управлять рабочими директориями, переменными среды и сетевыми портами, мы поговорим в следующем шаге.

Окружение и порты: Настройка метаданных через ENV, WORKDIR и EXPOSE

Окружение и порты: Настройка метаданных через ENV, WORKDIR и EXPOSE

Представьте, что вы написали идеальный скрипт запуска приложения. Он копирует файлы, устанавливает зависимости и запускает бинарник. Но при старте контейнер падает с ошибкой: Error: Cannot find module 'server.js'. Вы проверяете пути — всё верно. Оказывается, процесс PID 1 запустился в корневой директории /, а ваши файлы лежат в /app.

В предыдущих главах мы научились переносить файлы внутрь образа (COPY) и определять стартовый процесс (CMD и ENTRYPOINT). Но для корректной работы приложению нужен контекст: правильная рабочая папка, переменные окружения и задокументированные сетевые порты. За всё это отвечают инструкции метаданных.

Ловушка слоёв: почему RUN cd не работает

Самая частая ошибка новичков при настройке рабочей директории — попытка мыслить категориями обычного bash-скрипта. Желая перейти в папку с проектом, разработчик пишет:

RUN mkdir /app
RUN cd /app
RUN npm install

Сборка завершится ошибкой: npm ERR! code ENOENT: no such file or directory, open '/package.json'.

Вспомните механику слоёв из первой главы. Каждая инструкция RUN запускает новый временный контейнер от состояния предыдущего слоя.

  1. RUN mkdir /app — создает папку. Слой сохраняется.
  2. RUN cd /app — запускает новый контейнер, переходит в папку, завершается. Слой сохраняется, но текущая директория не является частью файловой системы. Это состояние процесса в памяти, которое исчезает вместе с завершением шага.
  3. RUN npm install — запускает новый контейнер. По умолчанию он стартует в корне /, где нет никаких исходников.

Чтобы задать директорию глобально и персистентно, используется инструкция WORKDIR.

WORKDIR: Якорь для файловой системы

WORKDIR работает как глобальная команда cd, состояние которой сохраняется для всех последующих инструкций в Dockerfile.

WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
CMD ["node", "server.js"]

Особенности WORKDIR:

  1. Автоматическое создание: Если указанной директории нет, Docker создаст её сам. Предварительный RUN mkdir не нужен.
  2. Относительные пути: Все пути в COPY, ADD, RUN, CMD и ENTRYPOINT теперь отсчитываются от WORKDIR. В примере выше COPY package.json . копирует файл именно в /app.
  3. Накопительный эффект: Если вы используете относительный путь в следующем WORKDIR, он приклеится к предыдущему:
    WORKDIR /opt
    WORKDIR app
    RUN pwd # Выведет /opt/app
    

Использование WORKDIR избавляет от необходимости прописывать абсолютные пути (/app/src, /app/bin) в каждой строке, делая Dockerfile читаемым и защищенным от опечаток.

ENV: Конфигурация через переменные окружения

Хорошей практикой (согласно методологии Twelve-Factor App) считается хранение конфигурации в переменных окружения. В Dockerfile для этого применяется инструкция ENV.

Синтаксис прост: ENV КЛЮЧ=ЗНАЧЕНИЕ.

WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
COPY . .
CMD ["node", "server.js"]

Инструкция ENV обладает важным двойным действием:

  1. Во время сборки: Переменная доступна для всех последующих инструкций RUN. Например, если вы напишете RUN echo $NODE_ENV, в лог выведется production.
  2. Во время выполнения: Переменная «запекается» в метаданные образа. Когда контейнер стартует, процесс PID 1 (ваш CMD) получит эти переменные в своё системное окружение. Приложению на Node.js будет доступно значение process.env.PORT.

Значения, заданные через ENV в Dockerfile, являются дефолтными. При запуске контейнера их можно переопределить из консоли (это мы разберем в следующей главе).

EXPOSE: Самая неправильно понимаемая инструкция

Если наше приложение слушает порт 8080, мы должны сообщить об этом Docker.

EXPOSE 8080

Существует опасный миф: «Инструкция EXPOSE открывает порт в фаерволе и делает приложение доступным снаружи». Это неправда.

Инструкция EXPOSE вообще не влияет на маршрутизацию трафика или сетевой стек хост-машины. Это исключительно метаданные — документация для человека, читающего Dockerfile, и подсказка для внешних систем маршрутизации.

Если вы напишете EXPOSE 8080, а приложение внутри слушает порт 3000, трафик не пойдет волшебным образом куда нужно. Если вы удалите EXPOSE из файла, но правильно настроите проброс портов при запуске контейнера — всё будет отлично работать.

По умолчанию EXPOSE подразумевает протокол TCP. Если ваше приложение использует UDP (например, DNS-сервер), это нужно указать явно: EXPOSE 53/udp.

Собираем всё вместе

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

# Базовый образ
FROM node:18-alpine

# Устанавливаем рабочую директорию
WORKDIR /opt/webapp

# Задаем переменные окружения по умолчанию
ENV NODE_ENV=production
ENV PORT=3000

# Копируем зависимости и устанавливаем их
# (Благодаря WORKDIR файлы попадут в /opt/webapp)
COPY package.json package-lock.json ./
RUN npm ci

# Копируем исходный код
COPY src/ ./src/

# Документируем порт
EXPOSE 3000

# Указываем процесс для PID 1
CMD ["node", "src/index.js"]

В этом файле больше нет хаоса из абсолютных путей, конфигурация вынесена в переменные, а сетевые намерения задокументированы. Образ готов к сборке и запуску — именно этим мы займемся на практике в следующей главе, где Docker CLI превратит наш текстовый манифест в работающий сервис.

Сборка и запуск: Практический цикл создания кастомного образа через Docker CLI

Сборка и запуск: Практический цикл создания кастомного образа через Docker CLI

У нас на руках есть готовый Dockerfile — текстовый файл с инструкциями FROM, WORKDIR, COPY, ENV, EXPOSE и CMD. Но сам по себе этот файл ничего не выполняет. Это лишь чертеж. Чтобы получить работающее приложение, чертеж нужно передать на завод (Docker-демону), который соберет по нему деталь (образ), а затем запустит её в работу (контейнер).

Весь практический цикл работы инженера с Docker сводится к двум фундаментальным командам CLI: сборке и запуску.

Этап 1: Сборка образа (docker build)

Команда docker build читает Dockerfile строка за строкой, выполняет инструкции, кэширует слои и на выходе выдает готовый к запуску образ.

Базовый синтаксис выглядит так: docker build -t my-app:1.0 .

Разберем два критически важных элемента этой команды.

Флаг тегирования (-t или --tag) Образы идентифицируются по их хэш-суммам (например, sha256:7b3b...), но работать с ними неудобно. Флаг -t присваивает образу человекочитаемое имя и тег в формате имя:тег.

  • Имя (my-app) указывает на принадлежность к проекту или сервису.
  • Тег (1.0) обычно обозначает версию.

Если вы напишете просто docker build -t my-app . (без двоеточия и версии), Docker автоматически присвоит образу тег latest. Важно понимать: latest — это не магическое слово, гарантирующее самую свежую версию кода. Это просто текстовая строка по умолчанию. Если вы забудете обновить образ с тегом latest, он так и останется старым.

Точка в конце команды (.) Это самая частая причина ошибок у новичков. Точка в конце — это не просто «сохрани образ сюда». Это путь к контексту сборки (build context).

Когда вы нажимаете Enter, Docker-клиент берет всё содержимое директории, на которую указывает этот путь (в случае точки — текущей директории), фильтрует его через .dockerignore и отправляет Docker-демону. Именно внутри этой переданной папки демон будет искать Dockerfile и файлы для инструкции COPY.

Этап 2: Запуск контейнера (docker run)

Как только образ собран, он оседает в локальном кэше демона. Теперь мы можем создать из него изолированную среду и запустить наш PID 1 процесс.

Базовая команда: docker run my-app:1.0

Если вы выполните её для веб-сервера, ваш терминал «зависнет», отображая логи приложения. Процесс привязался к вашему текущему сеансу оболочки. Нажмете Ctrl+C — отправите сигнал SIGINT, и контейнер остановится.

В реальной практике сервисы запускают в фоновом режиме. Для этого используется флаг detached mode: docker run -d my-app:1.0

Контейнер запустится в фоне, а терминал моментально вернет вам управление, выведя длинный ID созданного контейнера.

Связь с внешним миром: Порты и Окружение

Контейнер запущен в фоне, внутри него работает сервер. Но если вы откроете браузер и перейдете на localhost, вы ничего не увидите.

Инструкция EXPOSE 3000 в Dockerfile лишь задокументировала намерения разработчика. По умолчанию контейнер абсолютно изолирован: у него своя собственная виртуальная сеть и свой localhost, не связанный с вашим компьютером.

Проброс портов (Port Binding)

Чтобы трафик с вашей хост-машины (ноутбука или сервера) попадал внутрь контейнера, нужно явно пробить «туннель» при запуске. Это делается флагом -p (publish).

Синтаксис строгий: -p <порт_хоста>:<порт_контейнера>

Например: docker run -d -p 8080:3000 my-app:1.0

Теперь, когда вы обращаетесь к localhost:8080 на своем ноутбуке, Docker перехватывает этот трафик и перенаправляет его на порт 3000 внутрь изолированной сети контейнера, прямо к вашему приложению.

Переопределение переменных (ENV)

В Dockerfile мы могли задать значения по умолчанию через ENV LOG_LEVEL=info. Но прелесть контейнеризации в том, что один и тот же неизменный образ можно запускать в разных средах (Dev, Test, Prod), просто меняя настройки снаружи.

Флаг -e позволяет передать или переопределить переменные окружения прямо в момент запуска:

docker run -d -p 8080:3000 -e LOG_LEVEL=debug -e DB_HOST=prod-server my-app:1.0

Процесс внутри контейнера (ваш CMD) прочитает эти переменные из операционной системы контейнера точно так же, как если бы они были заданы в исходном коде или Dockerfile.

Полная картина: От кода до сервиса

Соберем все знания воедино. Представьте, что у вас есть директория с исходным кодом и следующим Dockerfile:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3
WORKDIR /app
COPY . .
ENV PORT=8000
EXPOSE 8000
CMD ["python3", "-m", "http.server", "8000"]

Ваш рабочий процесс в консоли будет состоять ровно из двух шагов:

  1. Создание артефакта: docker build -t python-server:v1 . (Клиент отправляет файлы демону, демон скачивает Ubuntu, ставит Python, копирует ваш код, сохраняет образ).

  2. Запуск сервиса: docker run -d -p 80:8000 -e PORT=8000 python-server:v1 (Демон создает изолированную среду, пробрасывает 80-й порт хоста на 8000-й порт контейнера и запускает Python-сервер в фоне).

Вы только что прошли полный путь: от написания инфраструктурного кода до запуска готового микросервиса.