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`, мы получаем готовую операционную среду. Однако пока эта среда пуста — в ней есть интерпретаторы и утилиты, но нет нашего собственного кода.