Если ты пишешь 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 делает две вещи: сначала собирает зависимости, потом создаёт чистый образ с приложением. Так получается меньше мусора и быстрее сборка.

Первая часть — сборка зависимостей:

  1. Берём лёгкий образ Python 3.12.1

  2. Ставим build-essential — он нужен для компиляции некоторых пакетов

  3. Копируем файл requirements.txt

  4. Собираем зависимости в wheel-файлы (это как zip-архивы для Python-пакетов)

Фишка в том, что build-essential останется только в этой временной части и не попадёт в итоговый образ.

Вторая часть — финальный образ:

  1. Снова берём чистый Python 3.12.1 без лишнего

  2. Настраиваем две важные переменные:

    • PYTHONDONTWRITEBYTECODE — чтобы не создавались .pyc-файлы

    • PYTHONUNBUFFERED — чтобы логи выводились сразу

  3. Копируем wheel-файлы из первой части

  4. Ставим зависимости из этих файлов (уже без интернета и компиляции)

  5. Копируем само приложение

  6. Запускаем 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)


  1. shsv382
    10.06.2025 14:00

    Спасибо за статью, заберу себе, даже если для своих проектов - всë равно полезно


    1. grosm4n Автор
      10.06.2025 14:00

      Спасибо, приятно. Вижу что все же не в пустоту пишу :)


  1. MadridianFox
    10.06.2025 14:00

    Шестой пункт неправильно написан.


    1. grosm4n Автор
      10.06.2025 14:00

      Что конкретно неверно и как на ваш взгляд должно быть? :)


      1. 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 вообще внутрь не копировать, а прямо на смонтированный натравить pip uv


  1. olku
    10.06.2025 14:00

    Какой пакет выставляет health эндпойнт?


    1. 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 будет проверять воздух.


      1. olku
        10.06.2025 14:00

        Спасибо вам за ответ, хотя фамильярность тут не всем заходит. Полагал, что есть аналог actuator.


        1. grosm4n Автор
          10.06.2025 14:00

          Спасибо за замечание. :) Постараюсь держать тон точнее, все же еще считаюсь здесь "новичком"

          Увы, налогов актуатора в мире Python/Node по умолчанию нет.
          Всё чаще делают руками — отдельный /health или /ping, который сам отвечает "200 OK", если всё норм.

          Но есть библиотеки, если хочется автоматизации:
          > для Python: healthcheck, fastapi-health, starlette-exporter
          > для Node: express-healthcheck, terminus


          1. olku
            10.06.2025 14:00

            Лишь наблюдение. Замечания тут НЛО делает. А для метрик Prometheus какую библиотеку в Питоне любят чаще всего? Техстек не мой, для общего развития интересуюсь.


            1. grosm4n Автор
              10.06.2025 14:00

              prometheus_client — официальный клиент.

              Он простой, гибкий, без лишнего. Работает с любыми фреймворками, особенно популярен с FastAPI, Flask и Django.

              Для него еще есть обертки формата fastapi-prometheus или django-prometheus, но чаще используется именно чистый клиент


  1. izibrizi2
    10.06.2025 14:00

    Спасибо! А что с /tmp в образе? Её нужно монтировать на хост?


    1. grosm4n Автор
      10.06.2025 14:00

      Нет, монтировать /tmp из контейнера на хост не нужно.

      По умолчанию /tmp — это временная папка внутри контейнера, и это ок. Она очищается при удалении.

      Монтируй только если:
      – нужен доступ к этим файлам снаружи
      – tmp‑кеш должен пережить перезапуск
      – отлаживаешь что-то и хочешь глянуть содержимое

      Но просто так — не трогай. Всё работает без этого.


      1. hogstaberg
        10.06.2025 14:00

        tmp‑кеш должен пережить перезапуск

        Стандарты ожидают, что /tmp является volatile. То есть не сохраняет содержимое при перезапуске и вообще всегда имеет под собой tmpfs или zram. В качестве non-volatile директории для временных файлов выступает /var/tmp.


  1. Hk_3dva
    10.06.2025 14:00

    в 6 пункте вы говорите что желательно использовать ENTRYPOINT
    хотя в изначальном примере все же используете CMD
    Можете рассказать почему вы все же используете вариант с CMD


    1. grosm4n Автор
      10.06.2025 14:00

      В самом первом примере - вариант именно плохого Dockerfile, в том числе по причине cmd.
      В третьем пункте - подразумевается запуск docker run myapp без каких-либо дополнительных тегов, но если планируется как-то распространять или с проектом работают несколько человек, то разумеется такой вариант тоже будет не самым лучшим решением и правильнее будет использовать ENTRYPOINT["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]


    1. hogstaberg
      10.06.2025 14:00

      Entrypoint нужен ровно для одного: чтобы сунуть туда что-то, исполняющее три базовые обязанности 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 можно только когда вы очень хорошо понимаете что именно пихаете, зачем вы это пихаете и как это будет (или не будет) пинать потенциальные дочерние процессы. Иначе легко можно нарваться на ситуацию, когда родительский процесс в контейнере завершился раньше дочернего, а подхватить этот дочерний процесс и забрать его код завершения некому. Ура, вы только что начали зомби-апокалипсис в отдельно взятом компукторе!


  1. a3000d
    10.06.2025 14:00

    Спасибо за статью, забрал в закладки. Если можете, распишите CI часть подробнее с примерами.


  1. fongostev
    10.06.2025 14:00

    • Брать python:3.12-slim или python:3.12-alpine, но только если понимаешь, как с ним работать и для чего ты его выбираешь.

    Отличная статья, спасибо. Хочу внести небольшое дополнение. По опыту для большинства python приложений достаточно slim образа + pyproject.toml. Если надо поставить какую-нибудь сложную библиотеку, типа pytorch, cupy или подобное - ищем образ от вендора, проиграешь по объёму, выиграешь по скорости развертывания и сэкономишь много нервных клеток. Alpine, конечно, намного компактнее, но там могут быть неожиданные проблемы с чем угодно: другой пакетный менеджер, свои версии системных зависимостей, нюансов много.


  1. kozlyuk
    10.06.2025 14:00

    1. Если сделать так, как рекомендуется (ENTRYPOINT + CMD), то docker run myapp bash, которым, видимо, пытаемся запустить bash в контейнере, запустит как раз python app.py bash.

    2. Для чего создавать нового пользователя, а не брать готового nobody:nogroup (65534:65534 в случае FROM scratch)?

    3. || exit 1 можно убрать. Нет, нельзя.


    1. Oraclist
      10.06.2025 14:00

      7.Возможно для того, чтобы прокинуть пользователя в хостовую ОС или получить оттуда.


  1. redfox0
    10.06.2025 14:00

    Не рассказано, как можно посмотреть содержимое каждого слоя. И некоторые советы противоречат друг другу: то сказано собирать зависимости в отдельном образе, то опять ставится всё вместе и потом делается попытка вычистить лишнее (вслепую? содержимое слоя-то мы не видим).

    Про podman ни слова опять же.


    1. olivera507224
      10.06.2025 14:00

      Про podman ни слова

      В статье про докер, действительно.


  1. chirizxc
    10.06.2025 14:00

    еще можно взять uv, чтобы ускорить установку библиотек


    1. sirmax123
      10.06.2025 14:00

      Это настолько ускоряет что прям имеет смысл? (я не пробовал но тянуть замену pip стремновато)
      Обычно (при наличии локальной прокси с нужными версиями, конечно) это и так не долго.

      Вопрос, если что, без подвоха


      1. chirizxc
        10.06.2025 14:00

        в некоторых кейcах может быть до x100 ускорение, создание виртуального окружения тоже быстрее x10-15, в целом тулинг от astral.sh уже можно назвать проверенным


        1. chirizxc
          10.06.2025 14:00

          ну и с коробки есть поддержка pip интерфейса, достаточно перед командой pip install ...поставить uv


    1. Radmin
      10.06.2025 14:00

      А ещё, покуда у нас в статье настолько внимательно считают мегабайты, uv шикарно запускается при сборке и будучи примонтированным из временного образа, который, в результате, не попадёт в итоговый слой (как и .lock-файл с pyproject-конфигом). Ссылка на пример использования фичи: https://docs.astral.sh/uv/guides/integration/docker/#using-uv-temporarily

      Да и, вообще, по этой ссылке весь раздел руководства я нахожу весьма полезным. Может быть, даже чуточку полезнее некоторых статей.


  1. Oraclist
    10.06.2025 14:00

    6.Entrypoint есть всегда. По умолчанию /bin/sh -с т.о. запускается /bin/sh -c python app.py
    В случае с командой docker run myapp bash выполнится /bin/sh -c bash


  1. emulio
    10.06.2025 14:00

    Добавлю что команда HEALTHCHECK исключительно для Docker демона. Она не поддерживается другими контейнерными движками.
    Podman будет игнорить, открытая issue https://github.com/containers/podman/issues/18904
    Kubernetes тоже будет игнорить, там в коде Deployments нужно свои пробы задавать (Liveness, Readiness, Startup)


  1. Botinke
    10.06.2025 14:00

    Годная статья.
    Спасибо!
    утащил в закладки.


  1. titan_pc
    10.06.2025 14:00

    COPY . .

    И потом огромный пухлый .dockerignore.

    Лучше просто копировать нужные папки и файлы. Так Вы полностью контроллируете что будет внутри.

    Установку библиотек следует сбилдить в 1 докер img, который положить в свой docker registry. Когда речь про микросервисную разработку - так удобнее. А от него уже наследоваться. И плодить микросервисы.

    Потому что микросервисы на 1м проекте как правило используют одни и те же библиотеки.


    1. funca
      10.06.2025 14:00

      Лучше просто копировать нужные папки и файлы. Так Вы полностью контроллируете что будет внутри.

      Там есть нюансы. При запуске сборки, docker сначала формирует контекст, исключая указанное в .dockerignore. Затем он отправляет его докер демону. Все команды в Dockerfile взаимодействуют с копией рабочей директории из контекста.

      Поэтому наличие .dockerignore может существенно сокращать время сборки за счёт уменьшения размера передаваемых докеру данных. Чтобы не писать огромные .dockerignore можно заранее копировать нужное в отдельную директорию или .tar архив, которые потом использовать в качестве контекста.

      Подробнее здесь https://docs.docker.com/build/concepts/context/


  1. msventukh
    10.06.2025 14:00

    Спасибо за статью, по делу и коротко.

    У меня будет пара (субъективных) комментариев:

    1. Я бы не стал указывать версию образа с точностью до версии патча (то есть вместо python:3.12.1-slimиспользовал бы python:3.12-slim). Причина: обычно подтягивать патчи уязвимостей и мелких багов — это хорошая идея.

    2. Ценность .dockerignore сильно варьируется в зависимости от языка программирования. Если в случае с python он действительно позволяет не тянуть лишний мусор (поскольку мы обычно копируем все исходники), то в случае с той же Java как правило достаточно скопировать один только jar-файл. В этом случае можно обойтись без развесистого .dockerignore и использовать менее жадное копирование. Впрочем, @titan_pc выше уже отметил этот момент.

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