Как готовить Dockerfile: больше, чем FROM и RUN
Cобираем Dockerfile для продакшена: от простейшего рабочего варианта до оптимизированного и безопасного multi-stage-решения.


Андрей Шилов
Инженер по DevOps-практикам
Я работаю в «Экспресс 42» — подразделении «Фланта», которое консультирует компании по внедрению и использованию практик DevOps-методологии.
В этой статье я расскажу, как подготовить Dockerfile — основу любого контейнерного образа: как избежать типичных ошибок и сделать Dockerfile максимально подходящим для продакшена.
Что такое Dockerfile
Готовое приложение должно стабильно собираться и работать в любых условиях. Этого можно добиться, если поместить код приложения и все необходимые ему компоненты (ОС, библиотеки, фреймворки) в изолированную среду — контейнер.
Для приложения важно, чтобы контейнер, в котором оно запускается, всегда собирался правильно и единообразно. Чтобы этого добиться, нужно подготовить специальную инструкцию — Dockerfile. В этом файле описывают, какой базовый образ взять за основу, какое ПО установить, какие команды выполнить при запуске приложения и так далее.
Dockerfile можно составить по-разному, и даже самые примитивные его варианты могут работать, но это не значит, что их стоит использовать. Давайте вместе пройдём путь от самого простого Dockerfile до состояния production ready.
1. Лишь бы запустилось
Представим, что мы написали приложение на Go, которое нужно поскорее залить в продакшен. Подготовим простейший Dockerfile:
Тут я взял последнюю версию образа Golang, скопировал весь проект, установил зависимости, собрал и запустил бинарник.
Внешне всё хорошо: образ собирается, приложение запускается. Но на самом деле у меня получился типичный результат из серии «минимум усилий — максимум проблем». И вот почему:
- Получившийся образ очень тяжёлый — весит 1,3 ГБ. В него попали папка .git, README, тесты и другие файлы, которые засоряют рантайм.
- Сборка непредсказуема, так как для базового образа Golang используется тег latest — завтра версия Go может измениться, и контейнер уже не будет идентичен текущему.
- Каждая новая сборка занимает 5–10 минут, потому что каждый билд начинается с нуля, а кеши слоев инвалидируются из-за появления новых файлов.
- Коллегам будет непонятно, кто собирал образ и зачем, — история сборок непрозрачная, так как нет меток (LABEL).
- Есть риски безопасности — команды в контейнере запускаются с правами пользователя root, поэтому любое уязвимое приложение получает права администратора внутри контейнера.
Получается, что первая версия Dockerfile работает, но плохо. Контейнер собрался, но его нельзя использовать в проде. Попробуем сделать образ менее крупным и более предсказуемым.
2. Первые улучшения: воспроизводимая сборка
Чтобы версия Golang не менялась от сборки к сборке, пропишем в Dockerfile конкретную версию. Это также сделает образ воспроизводимым и уменьшит его объём.
Добавим метки LABEL, чтобы было понятно, что это за образ и кто его поддерживает. Также благодаря меткам этот Dockerfile будет проще найти в registry.
Определим рабочую директорию (WORKDIR) для организации файлов внутри контейнера и заменим go mod tidy на go mod download. Дело в том, что tidy в Docker — плохая идея, потому что он меняет манифесты зависимостей, может модифицировать go.mod/go.sum. Это делает билд непредсказуемым: вы каждый раз рискуете получить чуть другой результат. go mod download лишён этого недостатка — он просто скачивает зависимости строго по зафиксированным манифестам.
И напоследок выделим в отдельный файл (.dockerignore) всё то, что не должно попадать в образ.
Вот что получилось:
Хорошие новости: образ стал весить меньше, стал воспроизводимым, а благодаря меткам и выделенной директории с ним стало удобнее работать. Но есть и плохая: образ по-прежнему не дотягивает до production ready.
- Для запуска образа нужен только бинарник, а в моём случае в финальном образе остаются исходники и кеш. Сборку и запуск лучше разделить, чтобы не тянуть в итоговый образ лишнее.
- Я добавил .dockerignore, но он не идеален: в образ по-прежнему попадают тесты, временные файлы, а возможно, и конфиденциальная информация, например токены, ключи.
- Я не выделил отдельного пользователя, от которого запускается контейнер, а значит, не решил проблему запуска от root, которая была ещё на прошлом этапе.
Итак, как видим, необходимо оптимизировать сборку и запуск образа, а также обеспечить его безопасность.
3. Multi-stage build: разделяем сборку и запуск образа
Прежде всего, избавимся от лишнего: чтобы в финальный образ не попадали исходники и кеш, применим multi-stage-подход к созданию Dockerfile. Он предполагает, что сборка и запуск образа происходят на разных стадиях (stages). Таким образом, все зависимости и необходимые для сборки файлы останутся только на первой стадии.
Чтобы решить проблему с root, добавим группу пользователей и конкретного пользователя, от имени которого будет запускаться приложение.
Также учтём потребности приложения в runtime-зависимостях и конфигурации. Наше приложение работает с PostgreSQL, поэтому добавим в образ клиент postgresql17-client — он понадобится для запуска миграций через psql перед стартом приложения. А ещё приложение использует токен для авторизации API-запросов — пока передадим его через переменную окружения API_TOKEN.
В итоге Dockerfile будет выглядеть так:
Разберём, что ещё было сделано, чтобы оптимизировать образ. На этапе сборки я:
- зафиксировал зависимости — скачал их строго по зафиксированному go.sum;
- собрал бинарник для Linux, чтобы сборка всегда проходила верно, даже если builder-образ изменится или сборка будет кросс-платформенной;
- явно задал имя бинарника через флаг -o myapp — это гарантирует, что имя файла всегда совпадёт с тем, на которое ссылаются COPY и ENTRYPOINT, даже если имя модуля в go.mod отличается от названия приложения;
- сделал Go-бинарник независимым от системных библиотек, что позволит избежать проблем с совместимостью;
- использовал ENTRYPOINT вместо CMD — теперь ./myapp нельзя случайно переопределить при docker run, а переданные аргументы будут добавляться к команде, а не заменять её.
На этапе подготовки финального образа теперь:
- итоговый образ минимальный и не содержит компилятора Go;
- копируется только бинарник, а не все файлы проекта;
- явно указано, что нужно приложению для запуска (PostgreSQL-клиент);
- кеш пакетного менеджера apk не создаётся и не увеличивает размер образа.
Мы решили проблемы, которые выделили на предыдущем этапе. Dockerfile вроде бы готов к продакшену, но на всякий случай пройдёмся по файлу ещё раз. На этапе запуска можно заметить, что в ENV зашит секретный токен — из-за этого образ точно не пройдёт аудит безопасности, так как конфиденциальные данные нельзя хранить в коде.
Кроме того, контейнеру не помешает HEALTHCHECK — без него Docker не узнает, отвечает приложение или зависло. Также, поскольку Dockerfile уже достаточно объёмный и сложный, можно добавить комментарии к элементам, которые важны для поддержки, но со временем перестанут быть очевидными. И последнее: так как образ предназначен для продакшена, лейблы можно подогнать под стандарты OCI, принятые в большинстве компаний.
4. Финальный рывок: контейнер, которому можно доверять
По традиции учтём недочёты предыдущего этапа и исправим Dockerfile.
Что изменилось:
- Я подогнал лейблы под стандарты OCI — теперь они соответствуют правилам, принятым в корпоративной среде.
- Добавил аргументы BUILD_TIME и VERSION, чтобы время сборки и версия образа динамически добавлялись в метаданные из CI/CD. Это позволит не редактировать Dockerfile вручную при каждом релизе и сделает его более управляемым и прозрачным для аудита.
- Объединил инструкции RUN в одну строку, чтобы сделать образ более компактным и улучшить читабельность.
- Добавил комментарии и инструкции для указания порта и проверки доступности контейнера.
- Определил запуск и поведение контейнера через ENTRYPOINT и CMD.
Теперь в нашем Dockerfile не хранятся чувствительные данные, и он наконец соответствует всем формальным требованиям для продакшена.
Вместо заключения: почему лучшие практики не всегда следует исполнять
Сейчас мало кто работает с Dockerfile в терминале — чаще всего это делается через оркестратор, Kubernetes или как минимум Docker Compose. Поэтому, создавая Dockerfile, стоит помнить, что он не существует в отрыве от инфраструктуры. В таких условиях некоторые компоненты передаются в файл извне, а то, что считается лучшей практикой, не всегда применимо в реальности.
Так, в нашем случае передача порта, проверки доступности и параметры запуска могут быть определены в конфигурации инфраструктуры, например в docker-compose.yaml или в манифесте Deployment. Поэтому, чтобы не дублировать инструкции, можно удалить EXPOSE, HEALTHCHECK и CMD.
Вот наш итоговый оптимизированный, стабильный, multi-stage Dockerfile:
В этой статье мы ограничимся таким Dockerfile, однако при желании его можно ещё улучшить: например, вместо версии базового образа использовать хеш контейнера из registry, подключить hadolint для автоматического линтинга, написать README с инструкциями по сборке и запуску, унести секреты в специальное хранилище, добавить проверку на уязвимости.












