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

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

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

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

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

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

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

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

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

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

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

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

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

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

!Архитектура базового образа

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

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

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

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

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

    Shell-форма:

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

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

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

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

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

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

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

    !Послойная сборка образа

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    !Схема передачи контекста сборки от Docker CLI к Docker Daemon

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Множественные источники:
  • Инструкция поддерживает указание нескольких файлов. В этом случае путь назначения строго обязан заканчиваться слешем, иначе сборка упадет с ошибкой. 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-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 скачивает архив (создается слой 1). Затем RUN распаковывает его и удаляет оригинал (создается слой 2). Архив физически удален в слое 2, но навсегда остался лежать в слое 1, увеличивая вес образа.

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

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

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

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

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

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