Если ты пишешь Dockerfile
, скорее всего, он работает. Но вопрос не в том, работает ли. Вопрос в другом: будет ли он работать через неделю, на другом сервере, в CI/CD, на чужом железе — и будет ли это безопасно. Или всё сломается, потому что ты не зафиксировал зависимости, положился на latest
, и забыл про то, что ENTRYPOINT
— это тоже код.
В этой статье — как собрать нормальный Docker-образ, который предсказуем, устойчив и готов к продакшену.
1. Первая ошибка: ты начинаешь с плохой базы
Многие берут базовый образ, не задумываясь. Например, python:3.12
. Это "толстый" образ с кучей ненужных пакетов. Он может весить 1+ ГБ. Там куча системных библиотек, что увеличивает потенциальную поверхность для атак и делает билды медленнее.
Плохой Dockerfile
:
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "main.py"]
Что тут не так:
Образ большой.
Все слои кэша сбиваются при любом изменении.
Ты клонируешь весь проект внутрь, включая мусор.
Как лучше:
Брать
python:3.12-slim
илиpython:3.12-alpine
, но только если понимаешь, как с ним работать и для чего ты его выбираешь.Использовать многоступенчатую сборку: сначала билд, потом перенос нужного в "чистую" фазу.
Указывать чёткую структуру слоёв: сначала зависимости, потом код.
2. Не используй latest
latest
— это ловушка. Сегодня образ один, завтра он обновится, и всё сломается. Причём сломается неожиданно — у тебя в проде или в CI.
Пример:
FROM node:latest
В понедельник это Node 18. В среду — уже 20. И твой build падает, потому что какой-то пакет несовместим.
Лучше указывать конкретную версию:
FROM node:18.16.1
Или даже с sha256:
FROM node@sha256:<digest>
Это не просто паранойя. Это способ зафиксировать окружение. Больше контроля — меньше сюрпризов.
3. Пример хорошего Dockerfile для Python‑приложения
FROM python:3.12.1-slim as builder
WORKDIR /install
RUN apt-get update && apt-get install -y build-essential
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip wheel --no-deps --wheel-dir /wheels -r requirements.txt
FROM python:3.12.1-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-deps --no-index --find-links=/wheels -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Этот Dockerfile делает две вещи: сначала собирает зависимости, потом создаёт чистый образ с приложением. Так получается меньше мусора и быстрее сборка.
Первая часть — сборка зависимостей:
Берём лёгкий образ Python 3.12.1
Ставим build-essential — он нужен для компиляции некоторых пакетов
Копируем файл requirements.txt
Собираем зависимости в wheel-файлы (это как zip-архивы для Python-пакетов)
Фишка в том, что build-essential останется только в этой временной части и не попадёт в итоговый образ.
Вторая часть — финальный образ:
Снова берём чистый Python 3.12.1 без лишнего
-
Настраиваем две важные переменные:
PYTHONDONTWRITEBYTECODE — чтобы не создавались .pyc-файлы
PYTHONUNBUFFERED — чтобы логи выводились сразу
Копируем wheel-файлы из первой части
Ставим зависимости из этих файлов (уже без интернета и компиляции)
Копируем само приложение
Запускаем uvicorn на порту 8000
Почему так лучше:
Итоговый образ меньше весит
Не нужно компилировать пакеты при каждом запуске
Сборка идёт быстрее за счёт кеширования wheel-файлов
Нет лишних пакетов типа build-essential в финальном образе
Если нужно добавить что-то в образ (например, curl для healthcheck), делай это перед последним COPY. Но помни — каждый RUN добавляет слой к образу.
4. .dockerignore — must-have
Без .dockerignore
ты случайно кладёшь в образ лишнее:
.git
pycache
.env
.vscode/
node_modules/
и т.д. и т.п.
Это увеличивает размер, сбивает кеш, и вообще плохо.
Пример .dockerignore
:
.git
__pycache__/
*.pyc
.env
.vscode/
node_modules/
*.log
Сделай это один раз — и забудешь про проблемы.
5. Volume: будь осторожен
Проблема: когда ты монтируешь volume, он затирает всё внутри контейнера.
docker run -v $(pwd):/app myapp
В результате:
Всё, что ты собрал — перезаписано.
Права доступа могут сломать работу.
Что делать:
Используй именованные volume:
docker volume create mydata
docker run -v mydata:/data myapp
Пропиши правильные права заранее.
6. CMD и ENTRYPOINT
Нюанс: CMD подменяется, ENTRYPOINT — остаётся.
Пример:
CMD ["python", "app.py"]
Ты запустил:
docker run myapp bash
А получил: python app.py bash
Правильно так:
ENTRYPOINT ["python", "app.py"]
CMD ["--debug"]
Теперь всё работает предсказуемо.
7. USER: не запускай всё от root
По умолчанию Docker запускается от root. Это опасно. Особенно если есть volume или доступ к сокету.
Добавь:
RUN useradd -m myuser
USER myuser
Теперь всё работает от безопасного пользователя. И если кто-то сломает контейнер, он не получит root.
8. Healthcheck
Docker считает, что контейнер жив, если он просто не умер. Но твой сервис может зависнуть или вернуть 500.
Добавь healthcheck:
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
Теперь Docker будет знать, когда сервис реально жив.
9. Кеш Docker
Слои в Docker кешируются. Если ты пишешь слои неправильно, кеш не работает.
Плохо:
COPY . .
RUN pip install -r requirements.txt
Лучше:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Теперь, если ты меняешь только код, зависимости не ставятся заново.
10. Docker в CI
Частые ошибки:
Пуш
latest
без тега.Кладут приватные ключи в образ.
Не используют
.dockerignore
.
Как надо:
Указывать теги явно:
myapp:1.2.3
Не хранить секреты в образе. Использовать secrets или переменные окружения.
Убедиться, что
.dockerignore
есть и рабочий.
11. Уменьшение размера образа
Каждый MB важен, особенно в CI/CD. Что можно сделать:
Убирать временные пакеты после использования.
RUN apt-get install -y gcc && pip install some-lib && apt-get remove -y gcc
Использовать
--no-cache
и чистить/tmp
.Применять
slim
, а не full-образы.
12. Alpine — не серебряная пуля
Да, он маленький. Но:
Использует musl, а не glibc.
Проблемы с совместимостью C-библиотек.
Долгий билд Python‑пакетов.
Используй Alpine только если ты понимаешь, зачем он тебе нужен. В остальных случаях — slim
.
Заключение
Сборка Docker-образа — это не только про "работает ли у меня". Это про устойчивость, безопасность и предсказуемость. Маленькие ошибки приводят к большим проблемам.
Используй лучшие практики — и будет меньше боли.
Если есть что добавить или считаешь что что-то из мной перечисленного можно отнести к "вредным советам" - милости прошу в комментарии. Я прочитаю и отредактирую, если твое предложение будет более актуально.
Комментарии (34)
MadridianFox
10.06.2025 14:00Шестой пункт неправильно написан.
grosm4n Автор
10.06.2025 14:00Что конкретно неверно и как на ваш взгляд должно быть? :)
hogstaberg
10.06.2025 14:00Например то, что entrypoint при создании контейнера из образа (да-да, тот самый
шмокер run
из примера в самой статье) можно переопределить?Also я лично обожаю заменять вот такие конструкции вида
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-deps --no-index --find-links=/wheels -r requirements.txt
на один RUN с
--mount bind
. Ибо во-первых это сложится в один слой, а во-вторых можно requirements.txt вообще внутрь не копировать, а прямо на смонтированный натравитьpipuv
olku
10.06.2025 14:00Какой пакет выставляет health эндпойнт?
grosm4n Автор
10.06.2025 14:00Никакой пакет сам по себе не делает
/health
. Это не “фича из коробки”.HEALTHCHECK
— это инструкция Docker'а, а эндпойнт ты должен сделать сам в коде.Например, для FastAPI:
@app.get("/health") def health(): return {"status": "ok"}
Потом в
Dockerfile
:HEALTHCHECK CMD curl -f http://localhost:8000/health || exit 1
Если ты не напишешь такой роут — Docker будет проверять воздух.
olku
10.06.2025 14:00Спасибо вам за ответ, хотя фамильярность тут не всем заходит. Полагал, что есть аналог actuator.
grosm4n Автор
10.06.2025 14:00Спасибо за замечание. :) Постараюсь держать тон точнее, все же еще считаюсь здесь "новичком"
Увы, налогов актуатора в мире Python/Node по умолчанию нет.
Всё чаще делают руками — отдельный/health
или/ping
, который сам отвечает "200 OK", если всё норм.Но есть библиотеки, если хочется автоматизации:
> для Python: healthcheck, fastapi-health, starlette-exporter
> для Node: express-healthcheck, terminusolku
10.06.2025 14:00Лишь наблюдение. Замечания тут НЛО делает. А для метрик Prometheus какую библиотеку в Питоне любят чаще всего? Техстек не мой, для общего развития интересуюсь.
grosm4n Автор
10.06.2025 14:00prometheus_client — официальный клиент.
Он простой, гибкий, без лишнего. Работает с любыми фреймворками, особенно популярен с FastAPI, Flask и Django.
Для него еще есть обертки формата fastapi-prometheus или django-prometheus, но чаще используется именно чистый клиент
izibrizi2
10.06.2025 14:00Спасибо! А что с /tmp в образе? Её нужно монтировать на хост?
grosm4n Автор
10.06.2025 14:00Нет, монтировать
/tmp
из контейнера на хост не нужно.По умолчанию
/tmp
— это временная папка внутри контейнера, и это ок. Она очищается при удалении.Монтируй только если:
– нужен доступ к этим файлам снаружи
– tmp‑кеш должен пережить перезапуск
– отлаживаешь что-то и хочешь глянуть содержимоеНо просто так — не трогай. Всё работает без этого.
hogstaberg
10.06.2025 14:00tmp‑кеш должен пережить перезапуск
Стандарты ожидают, что /tmp является volatile. То есть не сохраняет содержимое при перезапуске и вообще всегда имеет под собой tmpfs или zram. В качестве non-volatile директории для временных файлов выступает /var/tmp.
Hk_3dva
10.06.2025 14:00в 6 пункте вы говорите что желательно использовать ENTRYPOINT
хотя в изначальном примере все же используете CMD
Можете рассказать почему вы все же используете вариант с CMDgrosm4n Автор
10.06.2025 14:00В самом первом примере - вариант именно плохого Dockerfile, в том числе по причине cmd.
В третьем пункте - подразумевается запускdocker run myapp
без каких-либо дополнительных тегов, но если планируется как-то распространять или с проектом работают несколько человек, то разумеется такой вариант тоже будет не самым лучшим решением и правильнее будет использоватьENTRYPOINT["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
hogstaberg
10.06.2025 14:00Entrypoint нужен ровно для одного: чтобы сунуть туда что-то, исполняющее три базовые обязанности PID 1: подхватывать orphaned процессы, делать teardown zombie процессов и спускать полученные сигналы своим дочерним процессам. Например подойдет условный полулярный tini. И никогда это не менять. Поэтому, собственно, и появилось разделение на ENTRYPOINT и CMD. ENTRYPOINT изначально подразумевался как нечто намертво вшитое в образ, без возможности переопределить это в момент создания контейнера из образа (что позже радостно
починилисломали и разрешили entrypoint тоже менять). А в CMD вы уже пихаете то, что хотите запускать по дефолту при создании контейнера, причем это уже легко переопределяется ежели вы хотите запустить что-то иное.Короче говоря, вы практически всегда хотите видеть что-то типа такого (раз уж в статье часто упоминается python):
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "/path/to/python", "/path/to/hello-world.py*, "arguments", "more_arguments", "even_more_arguments" ]
И пихать что-то концептуально другое в entrypoint можно только когда вы очень хорошо понимаете что именно пихаете, зачем вы это пихаете и как это будет (или не будет) пинать потенциальные дочерние процессы. Иначе легко можно нарваться на ситуацию, когда родительский процесс в контейнере завершился раньше дочернего, а подхватить этот дочерний процесс и забрать его код завершения некому. Ура, вы только что начали зомби-апокалипсис в отдельно взятом компукторе!
a3000d
10.06.2025 14:00Спасибо за статью, забрал в закладки. Если можете, распишите CI часть подробнее с примерами.
fongostev
10.06.2025 14:00Брать
python:3.12-slim
илиpython:3.12-alpine
, но только если понимаешь, как с ним работать и для чего ты его выбираешь.
Отличная статья, спасибо. Хочу внести небольшое дополнение. По опыту для большинства python приложений достаточно slim образа + pyproject.toml. Если надо поставить какую-нибудь сложную библиотеку, типа pytorch, cupy или подобное - ищем образ от вендора, проиграешь по объёму, выиграешь по скорости развертывания и сэкономишь много нервных клеток. Alpine, конечно, намного компактнее, но там могут быть неожиданные проблемы с чем угодно: другой пакетный менеджер, свои версии системных зависимостей, нюансов много.
kozlyuk
10.06.2025 14:00Если сделать так, как рекомендуется (
ENTRYPOINT
+CMD
), тоdocker run myapp bash
, которым, видимо, пытаемся запуститьbash
в контейнере, запустит как разpython app.py bash
.Для чего создавать нового пользователя, а не брать готового nobody:nogroup (65534:65534 в случае
FROM scratch
)?Нет, нельзя.|| exit 1
можно убрать.
Oraclist
10.06.2025 14:007.Возможно для того, чтобы прокинуть пользователя в хостовую ОС или получить оттуда.
redfox0
10.06.2025 14:00Не рассказано, как можно посмотреть содержимое каждого слоя. И некоторые советы противоречат друг другу: то сказано собирать зависимости в отдельном образе, то опять ставится всё вместе и потом делается попытка вычистить лишнее (вслепую? содержимое слоя-то мы не видим).
Про podman ни слова опять же.
chirizxc
10.06.2025 14:00еще можно взять uv, чтобы ускорить установку библиотек
sirmax123
10.06.2025 14:00Это настолько ускоряет что прям имеет смысл? (я не пробовал но тянуть замену pip стремновато)
Обычно (при наличии локальной прокси с нужными версиями, конечно) это и так не долго.
Вопрос, если что, без подвохаchirizxc
10.06.2025 14:00в некоторых кейcах может быть до x100 ускорение, создание виртуального окружения тоже быстрее x10-15, в целом тулинг от astral.sh уже можно назвать проверенным
chirizxc
10.06.2025 14:00ну и с коробки есть поддержка
pip
интерфейса, достаточно перед командойpip install ...
поставитьuv
Radmin
10.06.2025 14:00А ещё, покуда у нас в статье настолько внимательно считают мегабайты, uv шикарно запускается при сборке и будучи примонтированным из временного образа, который, в результате, не попадёт в итоговый слой (как и .lock-файл с pyproject-конфигом). Ссылка на пример использования фичи: https://docs.astral.sh/uv/guides/integration/docker/#using-uv-temporarily
Да и, вообще, по этой ссылке весь раздел руководства я нахожу весьма полезным. Может быть, даже чуточку полезнее некоторых статей.
Oraclist
10.06.2025 14:006.Entrypoint есть всегда. По умолчанию
/bin/sh -с
т.о. запускается/bin/sh -c python app.py
В случае с командойdocker run myapp bash
выполнится/bin/sh -c bash
emulio
10.06.2025 14:00Добавлю что команда HEALTHCHECK исключительно для Docker демона. Она не поддерживается другими контейнерными движками.
Podman будет игнорить, открытая issue https://github.com/containers/podman/issues/18904
Kubernetes тоже будет игнорить, там в коде Deployments нужно свои пробы задавать (Liveness, Readiness, Startup)
titan_pc
10.06.2025 14:00COPY . .
И потом огромный пухлый .dockerignore.
Лучше просто копировать нужные папки и файлы. Так Вы полностью контроллируете что будет внутри.
Установку библиотек следует сбилдить в 1 докер img, который положить в свой docker registry. Когда речь про микросервисную разработку - так удобнее. А от него уже наследоваться. И плодить микросервисы.
Потому что микросервисы на 1м проекте как правило используют одни и те же библиотеки.
funca
10.06.2025 14:00Лучше просто копировать нужные папки и файлы. Так Вы полностью контроллируете что будет внутри.
Там есть нюансы. При запуске сборки, docker сначала формирует контекст, исключая указанное в .dockerignore. Затем он отправляет его докер демону. Все команды в Dockerfile взаимодействуют с копией рабочей директории из контекста.
Поэтому наличие .dockerignore может существенно сокращать время сборки за счёт уменьшения размера передаваемых докеру данных. Чтобы не писать огромные .dockerignore можно заранее копировать нужное в отдельную директорию или .tar архив, которые потом использовать в качестве контекста.
Подробнее здесь https://docs.docker.com/build/concepts/context/
msventukh
10.06.2025 14:00Спасибо за статью, по делу и коротко.
У меня будет пара (субъективных) комментариев:
Я бы не стал указывать версию образа с точностью до версии патча (то есть вместо
python:3.12.1-slim
использовал быpython:3.12-slim
). Причина: обычно подтягивать патчи уязвимостей и мелких багов — это хорошая идея.Ценность
.dockerignore
сильно варьируется в зависимости от языка программирования. Если в случае с python он действительно позволяет не тянуть лишний мусор (поскольку мы обычно копируем все исходники), то в случае с той же Java как правило достаточно скопировать один только jar-файл. В этом случае можно обойтись без развесистого.dockerignore
и использовать менее жадное копирование. Впрочем, @titan_pc выше уже отметил этот момент.
Удивительно, что ничего не сказано про сборку под конкретную платформу (хотя это была моя первая мысль, когда я увидел название статьи), но, возможно, это тут вне скоупа.
shsv382
Спасибо за статью, заберу себе, даже если для своих проектов - всë равно полезно
grosm4n Автор
Спасибо, приятно. Вижу что все же не в пустоту пишу :)