← назад к разделу

КОНТЕЙНЕРЫ: DOCKER Образы и Dockerfile

Обязательно

Образы, слои и Dockerfile

Java · Go · Node · Python

Как Dockerfile превращает приложение и окружение в образ из независимых слоёв и почему порядок инструкций влияет на кэш.

1. Образ хранит приложение и окружение

1 / 5

Образ содержит файловую систему, библиотеки и инструкцию запуска. Контейнер - процесс из этого образа с изменяемым слоем.

  • Базовый образ задаёт минимальную ОС.
  • Приложение добавляется своим слоем.
  • ENTRYPOINT определяет главный процесс.
App + ENTRYPOINTDependenciesBase OS + runtimeContainer: image + writable layer

2. Каждая инструкция создаёт слой

2 / 5

FROM, COPY, RUN и ADD формируют файловую систему. CMD и ENTRYPOINT задают поведение запуска, ENV - переменные.

  • COPY переносит только нужные файлы.
  • RUN устанавливает зависимости и собирает.
  • ENTRYPOINT exec форма передаёт сигналы.
FROM runtimeCOPY app.jarRUN buildENTRYPOINT execImage layersread-onlyИнструкции → файловая система + запуск

3. Порядок сохраняет кэш

3 / 5

Сначала копируются lock-файлы зависимостей, затем скачиваются зависимости, после - исходники. Правка кода не повторяет скачивание.

  • Lock-файл меняет слой зависимостей.
  • Код меняет только верхний слой.
  • .dockerignore уменьшает контекст.
Lock filesDependenciesSource codeКод изменился → кэш зависимостей сохранён

4. CMD и ENTRYPOINT имеют разные роли

4 / 5

ENTRYPOINT задаёт исполняемый процесс, CMD - аргументы по умолчанию. Exec форма не создаёт лишний shell и передаёт SIGTERM.

  • Shell форма может оставить процесс в стороне.
  • Приложение должно завершаться по сигналу.
  • Один основной процесс на контейнер.
ENTRYPOINT execPID 1: appSIGTERM → gracefulБез лишнего shell между Docker и приложением

5. Multi-stage оставляет только runtime

5 / 5

Сборка идёт в большом builder-образе, артефакт копируется в маленький runtime. Компилятор и кэш не попадают в production.

  • Builder содержит SDK.
  • COPY --from переносит артефакт.
  • Runtime содержит минимум файлов.
BuilderSDK + sources + buildRuntimeapp only + runtimeCOPY --from=builder

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

Главное здесь — порядок инструкций: он решает, что при следующей сборке возьмётся из кэша, а что соберётся заново.

меняется только код — что пересоберётся порядок: COPY выше RUN и ENV порядок: COPY в самом низу FROM eclipse-temurin:21-jre WORKDIR /app COPY app.jar RUN apt-get install unzip ENV TZ FROM eclipse-temurin:21-jre WORKDIR /app RUN apt-get install unzip ENV TZ COPY app.jar собрансобрансобрансобрансобрансобрансобрансобрансобрансобран изменился изменился из кэшаиз кэшазановозановозаново из кэшаиз кэшаиз кэшаиз кэшазаново выполнено 5 шагов из 5выполнено 5 шагов из 5 3 шага из 5 заново · unzip качается снова 1 шаг из 5 заново · unzip из кэша первая сборка: в обоих вариантах выполняются все 5 шагов правим код — новый app.jar, остальные инструкции те же кэш сброшен на COPY и на всём, что стоит ниже него редкое выше, частое ниже — 3 пересборки против 1
плохой порядок COPY статический бинарник COPY Go modules кэш работает COPY Go modules COPY статический бинарник

Сначала копируют зависимости и устанавливают их, приложение - последним. Тогда правка кода не заставляет скачивать зависимости заново.

плохой порядок COPY server.js COPY npm кэш работает COPY npm COPY server.js

Сначала копируют зависимости и устанавливают их, приложение - последним. Тогда правка кода не заставляет скачивать зависимости заново.

плохой порядок COPY app.py COPY pip кэш работает COPY pip COPY app.py

Сначала копируют зависимости и устанавливают их, приложение - последним. Тогда правка кода не заставляет скачивать зависимости заново.

Кэш сбрасывается не только на изменённом шаге, но и на всех, что идут ниже: копирование кода в середине тянет за собой переустановку зависимостей — 3 шага из 5 заново вместо 1.

Проблема: «у меня работает, а на сервере нет»

Откуда берётся «работает у меня, а на сервере нет», разобрано в предыдущей статье: на сервере другая версия языка, нет библиотеки, конфиг не там. Решение — упаковать приложение вместе с рантаймом и всеми зависимостями в единый образ. Тогда везде, где запущен Docker, поведение будет одинаковым: на ноутбуке, в CI, на сервере.

Образ — это неизменяемый шаблон, из которого создаются контейнеры. Один образ — сколько угодно контейнеров на его основе.

Что такое образ и слои

Образ Docker — это не просто архив с файлами. Внутри он состоит из слоёв (layers), уложенных один поверх другого.

Слой появляется не от каждой инструкции, а только от тех, что и правда меняют файлы: RUN, COPY и ADD. Слой фиксирует эти изменения: добавленные файлы, установленные пакеты, распакованный архив. Финальный образ — это стопка слоёв, которые Docker склеивает в единую файловую систему.

Остальные инструкции — WORKDIR, ENV, EXPOSE, CMD, ENTRYPOINT — на диск ничего не кладут. Они дописывают настройки в описание образа: где рабочая директория, какие переменные окружения, чем стартовать. Весят такие записи нисколько.

Короткая формула: образ = базовый образ + слои от RUN, COPY и ADD + настройки, которые дописали остальные инструкции.

Зачем слои? Для переиспользования кэша. Если вы поменяли только код приложения, Docker не будет повторно скачивать базовый образ и переустанавливать зависимости — эти слои уже есть локально. Перестраивается только то, что изменилось, и всё, что идёт после. Это значительно ускоряет повторные сборки.

ваш образ базовый рантайм уже готовый WORKDIR /app запись в настройках COPY приложение слой с файлами ENTRYPOINT [...] запись в настройках

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

Dockerfile — инструкция по сборке

Dockerfile — текстовый файл с набором инструкций. Docker читает его сверху вниз и выполняет каждую инструкцию, создавая очередной слой.

Разберём основные инструкции на реальном примере.

FROM — базовый образ

Образ не строят с нуля — нужна готовая система с рантаймом внутри, от которой отталкиваться:

FROM eclipse-temurin:21-jre
FROM gcr.io/distroless/static-debian12
FROM node:22-slim
FROM python:3.12-slim

FROM — первая и обязательная инструкция. Она говорит Docker, от какого образа отталкиваться. В Java-варианте eclipse-temurin:21-jre — официальный образ с JRE 21 (только runtime, без компилятора); для Node и Python берут официальные node:22-slim и python:3.12-slim, а финальному образу Go рантайм не нужен вовсе — статический бинарник кладут в distroless/static.

21-jre — это тег: версия образа. Всегда указывайте конкретный тег, а не latest — иначе сборка может сломаться при выходе новой версии.

WORKDIR — рабочая директория

Чтобы не писать /app/... в каждой следующей инструкции, задают рабочую директорию:

WORKDIR /app

WORKDIR задаёт директорию внутри контейнера, в которой будут выполняться последующие инструкции (COPY, RUN, CMD). Если директории нет — она создаётся автоматически. Это как сделать cd /app, только для Docker.

COPY — копирование файлов

Собранное приложение должно оказаться внутри образа:

COPY build/libs/app.jar app.jar
COPY bin/app app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist/ dist/
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/

COPY копирует файлы из вашей локальной файловой системы (слева) внутрь образа (справа). Здесь мы копируем артефакт сборки в рабочую директорию контейнера.

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

RUN — выполнение команд при сборке

Иногда базовому образу не хватает пакета или что-то нужно подготовить заранее, во время сборки:

RUN apt-get update \
 && apt-get install -y --no-install-recommends unzip \
 && rm -rf /var/lib/apt/lists/*

RUN выполняет команду во время сборки образа и сохраняет результат как новый слой. Используется для установки пакетов, создания директорий, любой подготовки окружения.

Несколько команд лучше объединять через && в одну инструкцию RUN — так получается меньше слоёв. Но одно объединение мусор не убирает: список пакетов, который скачал apt-get update, останется лежать в /var/lib/apt/lists и будет весить в образе десятки мегабайт. Поэтому в конце и стоит rm -rf — и именно в той же инструкции: если вынести удаление в отдельный RUN, файлы уже попадут в предыдущий слой, а верхний слой их только спрячет, не уменьшив образ.

Ключ --no-install-recommends — из той же серии: без него apt тянет ворох «рекомендованных» пакетов, которые вам не нужны.

Прежде чем что-то ставить, загляните, нет ли этого в базовом образе. В eclipse-temurin уже есть и curl, и wget — строка RUN apt-get install -y curl там только добавит лишний слой.

ENV — переменные окружения

Настройки рантайма удобно зашить в образ, чтобы не повторять их при каждом запуске:

ENV JDK_JAVA_OPTIONS="-XX:MaxRAMPercentage=75"
ENV GOMEMLIMIT=384MiB
ENV NODE_ENV=production NODE_OPTIONS="--max-old-space-size=384"
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1

ENV задаёт переменные окружения внутри образа. Они доступны как при сборке, так и при запуске контейнера. Удобно для настройки рантайма или передачи конфигурации приложению.

В Java здесь легко промахнуться с именем. Очень часто пишут ENV JAVA_OPTS="-Xmx512m" и ждут, что JVM это подхватит — она не подхватит. JAVA_OPTS читают стартовые скрипты (Tomcat, Maven, Gradle), а не сама команда java. Если запуск идёт строкой ENTRYPOINT ["java", "-jar", "app.jar"], переменная просто пролежит без дела, а приложение стартует с размером кучи по умолчанию. Саму java слушается переменная JDK_JAVA_OPTIONS — её и задавайте. У других рантаймов правило то же: задавать то, что читает сам рантайм — GOMEMLIMIT, GOMAXPROCS и GOGC у Go, NODE_OPTIONS у Node, PYTHONUNBUFFERED и прочие PYTHON* у Python.

Переменные, которые должны меняться при запуске (пароли, URL баз данных), лучше передавать через docker run -e или docker compose — не зашивать в образ.

EXPOSE — документирование порта

Тому, кто запускает образ, надо знать, на каком порту приложение слушает:

EXPOSE 8080
EXPOSE 8080
EXPOSE 3000
EXPOSE 8000

EXPOSE объявляет, на каком порту слушает приложение внутри контейнера. Сам по себе порт наружу он не открывает — реальный проброс настраивается при запуске контейнера (docker run -p).

Но «просто комментарием» это тоже не назовёшь: объявленные порты попадают в описание образа. Команда docker run -P (заглавная P) публикует наружу как раз их, сама подбирая свободные порты на хосте. Тот же список читают инструменты, которые работают поверх Docker. Ну и человеку, открывшему ваш Dockerfile, сразу видно, куда стучаться.

CMD и ENTRYPOINT — запуск приложения

Это самые часто путаемые инструкции. Обе задают, что выполнить при старте контейнера, но по-разному.

CMD — команда по умолчанию. Её можно заменить при запуске контейнера, передав другую команду в docker run. Например:

CMD ["java", "-jar", "app.jar"]
CMD ["./app"]
CMD ["node", "dist/main.js"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

docker run my-app:1.0 sh выполнит sh вместо команды по умолчанию.

ENTRYPOINT — фиксированная точка входа. То, что передаётся через docker run, не заменяет её, а добавляется к ней как аргументы. Пример:

ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["./app"]
ENTRYPOINT ["node", "dist/main.js"]
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0"]

docker run my-app:1.0 --debug добавит --debug к точке входа.

Самый распространённый приём — комбинация: ENTRYPOINT фиксирует исполняемый файл, CMD даёт аргументы по умолчанию (которые можно переопределить):

ENTRYPOINT ["java"]
CMD ["-jar", "app.jar"]
ENTRYPOINT ["./app"]
CMD ["serve"]
ENTRYPOINT ["node"]
CMD ["dist/main.js"]
ENTRYPOINT ["uvicorn", "app.main:app"]
CMD ["--host", "0.0.0.0", "--port", "8000"]
docker run my-app:1.0            # java -jar app.jar
docker run my-app:1.0 -version   # java -version: CMD заменён, ENTRYPOINT остался
docker run my-app:1.0            # ./app serve
docker run my-app:1.0 migrate    # ./app migrate: CMD заменён, ENTRYPOINT остался
docker run my-app:1.0            # node dist/main.js
docker run my-app:1.0 --version  # node --version: CMD заменён, ENTRYPOINT остался
docker run my-app:1.0                # uvicorn app.main:app --host 0.0.0.0 --port 8000
docker run my-app:1.0 --port 9000    # uvicorn app.main:app --port 9000: CMD заменён, ENTRYPOINT остался

Подменить саму точку входа всё-таки можно — флагом --entrypoint. Это первое, что пригодится, когда образ не стартует и хочется заглянуть внутрь:

docker run --rm -it --entrypoint sh my-app:1.0

Для простых случаев достаточно одного ENTRYPOINT. Используйте exec-форму (массив JSON, как выше) — она запускает процесс напрямую, без промежуточного shell, что важно для корректной обработки сигналов остановки контейнера.

Стоит объяснить, что именно ломает вторая форма, потому что «важно для сигналов» звучит как формальность, а последствия конкретные. Записанный строкой (ENTRYPOINT java -jar /app.jar) запуск Docker выполняет через shell: /bin/sh -c "java -jar /app.jar". Первым процессом в контейнере — с номером 1 — становится не приложение, а shell, а приложение запускается его дочерним процессом.

Дальше происходит вот что. docker stop отправляет процессу с номером 1 сигнал SIGTERM — просьбу завершиться. Shell этот сигнал получает, но дочернему процессу его не передаёт: он просто ждёт, пока тот закончит работу. Приложение о просьбе не узнаёт: обработчики завершения не выполняются, пул соединений не закрывается, текущие запросы обрываются на середине. Через десять секунд (срок по умолчанию) Docker теряет терпение и отправляет SIGKILL, который убивает всё без разговоров.

Внешне это выглядит как «контейнер останавливается ровно десять секунд» — узнаваемый признак shell-формы. С exec-формой номер 1 получает само приложение, SIGTERM доходит до него, оно корректно завершает работу, и контейнер останавливается за сотни миллисекунд.

Отсюда два практических правила. Массив JSON в ENTRYPOINT и CMD — всегда, кроме случаев, когда shell действительно нужен (подстановка переменных окружения в команду). И если нужна именно строковая форма, запускайте процесс через exec: ENTRYPOINT exec java -jar /app.jar (или exec ./app, exec node dist/main.js) заменяет shell собой, и номер 1 снова достаётся приложению.

только CMD аргументы заменяют CMD ls /app только ENTRYPOINT аргументы в хвост точка входа ls /app ENTRYPOINT + CMD аргументы заменяют CMD команда ls /app shell-форма ENTRYPOINT аргументы теряются sh -c команда запуска

Одна и та же команда docker run образ ls /app в четырёх записях запуска: смотрите, где ls /app подменяет CMD, где дописывается к ENTRYPOINT, а где вовсе теряется в оболочке.

Полный Dockerfile

Собираем всё вместе:

FROM eclipse-temurin:21-jre

WORKDIR /app

# копируем собранный jar (предполагается: ./gradlew bootJar уже выполнен)
COPY build/libs/app.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]
FROM gcr.io/distroless/static-debian12

WORKDIR /app

# копируем собранный бинарник (предполагается: CGO_ENABLED=0 go build -o bin/app ./cmd/app уже выполнен)
COPY bin/app app

EXPOSE 8080

ENTRYPOINT ["./app"]
FROM node:22-slim

WORKDIR /app

# зависимости отдельно: этот слой переживёт правку кода
COPY package*.json ./
RUN npm ci --omit=dev

# собранный код (предполагается: npm run build уже выполнен)
COPY dist/ dist/

EXPOSE 3000

ENTRYPOINT ["node", "dist/main.js"]
FROM python:3.12-slim

WORKDIR /app

# зависимости отдельно: этот слой переживёт правку кода
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ app/

EXPOSE 8000

ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Это минимальный рабочий Dockerfile. Он берёт собранное приложение, помещает его в образ и запускает при старте контейнера.

.dockerignore: что не уедет в сборку

Одна деталь про COPY, которую стоит узнать до первой сборки. Перед тем как выполнить Dockerfile, Docker собирает контекст сборки — то есть упаковывает каталог, указанный в конце команды (docker build . — это текущий каталог), и отправляет его сборщику. Целиком, со всем, что там лежит.

В обычном проекте это build/, target/, node_modules/ или .venv/ с прошлыми сборками, .git со всей историей, .gradle с кэшем, каталоги среды разработки, а иногда и локальный .env с паролями. Сотни мегабайт, из которых нужен один артефакт сборки. Последствия три: сборка медленно начинается («sending build context»), любое изменение в этих каталогах сбрасывает кэш слоя COPY, а лишние файлы могут попасть в образ, если COPY . . написан широко.

Лечится файлом .dockerignore рядом с Dockerfile — по смыслу это .gitignore для контекста сборки:

.git
.gradle
build
target
*.log
.env
.idea

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

Сборка образа: docker build

Собрать образ из Dockerfile можно командой:

# собрать образ и присвоить тег my-app:1.0
docker build -t my-app:1.0 .

Флаг -t (от tag) задаёт имя и тег образа. Точка в конце — это контекст сборки: директория, содержимое которой Docker может использовать в инструкциях COPY. Обычно это текущая директория проекта.

Посмотреть созданный образ:

docker images

После сборки образ можно запустить:

docker run -p 8080:8080 my-app:1.0
docker run -p 8080:8080 my-app:1.0
docker run -p 3000:3000 my-app:1.0
docker run -p 8000:8000 my-app:1.0

Ключ -p 8080:8080 пробрасывает порт: <порт на хосте>:<порт в контейнере>.

Кэш слоёв и порядок инструкций

Docker кэширует слои. Если при повторной сборке слой не изменился — он берётся из кэша, а не перестраивается. Но как только один слой изменился — все последующие пересобираются.

И тут важна деталь, которую обычно узнают через месяц отладки: Docker по-разному решает, изменился ли слой, в зависимости от инструкции.

Для COPY и ADD он считает контрольную сумму от содержимого и прав копируемых файлов. Изменили байт в файле — слой пересобирается; поменяли только время изменения файла — не пересобирается. Это то, чего все и ожидают.

Для RUN он смотрит только на текст команды. Не на результат, не на то, что происходит в сети, — на строку в Dockerfile. Отсюда классика:

RUN apt-get update && apt-get install -y curl

Текст не менялся полгода — значит слой все полгода берётся из кэша, и в образ ставятся пакеты того состояния списка, что было при первой сборке. Формально всё честно, практически — вы месяцами собираете образ с устаревшими, в том числе уязвимыми, пакетами. Лечится это тем, что apt-get update никогда не пишут отдельной инструкцией от установки (иначе кэшированное обновление совсем разъезжается с установкой), а обновление слоя провоцируют явно: меняют тег базового образа, пересобирают с --no-cache по расписанию или добавляют в команду аргумент, который меняется при сознательном обновлении.

То же правило объясняет и другую неожиданность: RUN git clone или RUN curl из кэша вернёт содержимое годичной давности, потому что текст команды тот же. Всё, что тянет данные из сети внутри RUN, кэшируется по строке — и это надо либо закреплять версией в самой команде, либо выносить из сборки.

Из этого следует важное правило: инструкции, которые меняются редко, ставьте выше; то, что меняется часто — ниже (именно это показывает схема в начале статьи).

Артефакт приложения меняется при каждой сборке, поэтому его COPY стоит в конце. Если бы COPY app.jar стоял выше, а установка зависимостей через RUN — ниже него, этот RUN пришлось бы выполнять заново при каждом изменении кода, хотя сами зависимости не менялись. Правильный порядок экономит минуты на каждой итерации.

И последнее, без чего вся глава про порядок инструкций может не сработать: кэш слоёв лежит на машине, которая собирает. На вашем ноутбуке он накапливается сам, а на чистом исполнителе конвейера его нет вовсе — там каждая сборка начинается с пустого кэша, и «правильный порядок» не экономит ни секунды, пока кэш не подключён отдельно. Как это делается, разбирает статья про слои и многоэтапную сборку.

Для ещё более эффективного кэширования (разделение зависимостей и кода приложения) используют многоэтапную сборку — это отдельная тема, разобранная в статье про слои и многоэтапную сборку.

Дополнительно: при первом чтении можно пропустить

Глубже: ADD и COPY: почему по умолчанию COPYрасширенное

Рядом с COPY в Dockerfile существует похожая инструкция ADD, и разница между ними — частый вопрос.

COPY делает ровно одно: копирует файлы из контекста сборки в образ. ADD умеет то же плюс две вещи, которые выглядят удобством, а работают как сюрприз:

  • Распаковывает архивы. ADD app.tar.gz /opt/ не положит архив, а распакует его. Если вы этого не ждали, в образе окажется дерево файлов вместо одного файла — и наоборот, кто-то рассчитывает на распаковку, а она не срабатывает для .zip.
  • Скачивает по ссылке. ADD https://example.com/lib.jar /opt/ загрузит файл из сети. Это ломает воспроизводимость (на том конце файл может измениться), не использует прокси и учётные данные так, как вы ожидаете, и не проверяет контрольную сумму.

Правило простое: по умолчанию COPY. ADD берут осознанно, когда нужна именно распаковка локального архива. Скачивать в образе лучше явным RUN curl -fsSL ... && echo "<sha256> file" | sha256sum -c: видно, откуда файл, и проверяется, что он тот самый.

Глубже: ARG и ENV: переменные сборки и переменные запускарасширенное

Следующий вопрос после «как собрать образ» — как передать в сборку номер версии или адрес внутреннего репозитория. Для этого есть две инструкции, и путать их неудобно.

ARG — переменная времени сборки. Объявляется в Dockerfile, значение приходит из командной строки, в запущенном контейнере её нет:

ARG APP_VERSION=dev
COPY build/libs/app-${APP_VERSION}.jar /app.jar
ARG APP_VERSION=dev
COPY bin/app-${APP_VERSION} /app/app
ARG APP_VERSION=dev
COPY dist-${APP_VERSION}/ /app/dist/
ARG APP_VERSION=dev
COPY dist/app-${APP_VERSION}-py3-none-any.whl /tmp/app.whl
docker build --build-arg APP_VERSION=1.4.2 -t my-app:1.4.2 .

ENV — переменная окружения, которая остаётся в образе и видна процессу при запуске:

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
ENV GOMEMLIMIT=384MiB
ENV NODE_OPTIONS="--max-old-space-size=384"
ENV PYTHONUNBUFFERED=1

Три вещи, которые важно знать про обе.

ARG до FROM и после — разные области видимости. Объявленный до первой инструкции FROM аргумент виден в самих строках FROM (это способ подставить версию базового образа), но не виден внутри этапа сборки; чтобы использовать его дальше, его объявляют ещё раз внутри этапа.

Значение ARG не секрет. Оно остаётся в истории образа, и docker history my-app:1.4.2 покажет его текстом любому, кто скачал образ. То же и с ENV. Поэтому пароли и токены так не передают вовсе — для них есть отдельный механизм монтирования секретов на время сборки, о котором статья про промышленные образы.

ENV виден всем процессам контейнера и попадает в вывод docker inspect. Настройки — да, пароль от базы — нет: его передают при запуске, а лучше монтируют файлом.

Коротко

  • Образ — неизменяемый шаблон для запуска контейнеров; состоит из слоёв.
  • Слои создают только RUN, COPY и ADD; WORKDIR, ENV, EXPOSE, CMD и ENTRYPOINT лишь дописывают настройки образа. Неизменённые слои берутся из кэша при повторной сборке.
  • Тег базового образа фиксируйте явно: eclipse-temurin:21-jre, node:22-slim, python:3.12-slim, а не latest. Несколько команд в одном RUN вместе с уборкой мусора в той же инструкции, а флаги рантайма через переменные, которые он читает сам (JDK_JAVA_OPTIONS у Java, JAVA_OPTS команда java не читает; GOMEMLIMIT у Go, NODE_OPTIONS у Node).
  • ENTRYPOINT фиксирует точку входа; CMD даёт аргументы по умолчанию (можно переопределить при запуске).
  • docker build -t имя:тег . собирает образ; . — контекст сборки. Редко меняющиеся инструкции ставьте выше по Dockerfile — так кэш используется эффективнее.
  • Контекст сборки уезжает целиком: .dockerignore с .git, build, .gradle и .env ускоряет сборку, спасает кэш COPY и не пускает лишнее в образ.
  • Кэш для COPY считается по содержимому файлов, а для RUN — только по тексту команды: поэтому apt-get update месяцами берётся из кэша, а RUN curl возвращает вчерашние данные.
  • По умолчанию COPY: ADD распаковывает архивы и скачивает по ссылке, и оба поведения неожиданны. ARG — переменная сборки, ENV остаётся в образе, и значения обеих видны в docker history.
  • Shell-форма ENTRYPOINT делает первым процессом shell: SIGTERM до приложения не доходит, остановка занимает ровно десять секунд и заканчивается SIGKILL; exec-форма или exec в команде это лечит.

Что почитать дальше