← Docker
18 мин · средне · Урок 4 из 8

Dockerfile

FROM, RUN, COPY, CMD, ENTRYPOINT, WORKDIR. Кеш слоёв, .dockerignore, USER non-root, multi-stage build.

Зачем Dockerfile

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

docker build -t myapp:1.0 .

Точка в конце — это context (что отправляется демону на сборку, обычно текущий каталог). -t myapp:1.0 — тэг получившегося образа.

Минимальный пример

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]

Сверху вниз:

  1. Базовый образ — официальный node на alpine.
  2. Все последующие команды работают из /app внутри образа.
  3. Копируем только манифесты зависимостей.
  4. Ставим зависимости.
  5. Копируем остальной код.
  6. Переключаемся с root на пользователя node.
  7. По умолчанию запускается node server.js.

Что делает каждая инструкция

ИнструкцияЧто делает
FROM image:tagбазовый образ. Обязательная первая строка. Можно несколько (multi-stage).
WORKDIR /pathcd для всех последующих инструкций и для дефолтного запуска.
COPY src destкопирует файлы из контекста сборки в образ.
ADDкак COPY, но ещё распаковывает архивы и качает по URL. ADD <URL> — антипаттерн: нет кеша и контроля, вместо него RUN curl. Для копирования файлов используй COPY.
RUN cmdкоманда на этапе сборки. Создаёт новый слой с результатом.
ENV KEY=VALпеременная окружения. Видна и при сборке, и в рантайме.
EXPOSE 8080документация: «контейнер слушает 8080». Не публикует порт.
USER userпереключиться на не-root для всех последующих RUN и для запуска.
CMD [...]команда по умолчанию. Переопределяется аргументами docker run.
ENTRYPOINT [...]закрепляет «что запускаем». CMD становится аргументами.
HEALTHCHECKкоманда для проверки живости.
VOLUME /pathпометить путь как том. Анонимный том создаётся при каждом docker run и легко плодит мусор, поэтому в образах для приложений ставят редко.

CMD vs ENTRYPOINT

Тут все спотыкаются. Три рабочих варианта.

Используй exec-форму ["a", "b"], а не shell-форму a b. С shell-формой Docker сам оборачивает команду и запускает /bin/sh -c "a b", так что pid 1 достаётся оболочке, а твой процесс становится её потомком. Сигнал от docker stop поймает sh, а не приложение.

Кеш слоёв и почему порядок важен

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

Отсюда правило: то, что меняется редко — выше. То, что часто — ниже.

Сравни два варианта.

Плохо:

FROM node:22-alpine
WORKDIR /app
COPY . .                 # <- меняется при каждой правке кода
RUN npm ci               # <- значит, npm ci пересчитывается каждый раз
CMD ["node", "server.js"]

Хорошо:

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # меняется редко
RUN npm ci                                # из кеша, пока манифесты те же
COPY . .                                  # меняется часто, дёшево
CMD ["node", "server.js"]

Во втором варианте, пока ты не трогал зависимости, npm ci берётся из кеша. Это разница между 30 секундами и 30 миллисекундами на пересборку.

FROM node:22-alpineиз кеша WORKDIR /appиз кеша COPY package.json ./из кеша RUN npm ciиз кеша COPY . .код поменялся CMD ["node", "server.js"] промах: пересборка и всё, что ниже, тоже
Кеш держится до первой изменившейся инструкции. Ниже неё всё пересобирается заново.

Когда меняешь только код (но не package.json), в кеше остаются 4 первые строки. Пересобирается COPY . . и всё, что стоит ниже неё.

Подвох с обновлением списка пакетов:

RUN apt-get update             # ← закешируется отдельным слоем
RUN apt-get install -y curl    # ← возьмёт устаревший cache из предыдущей строки

Нужно склеивать в одну инструкцию:

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

Так и кеш правильный (одна строка — одно состояние), и образ не раздувается мусором из /var/lib/apt/lists.

В скриптах и Dockerfile пиши apt-get, а не apt. У apt нет стабильного CLI: вывод и флаги меняются между релизами, он сам предупреждает об этом строкой «apt does not have a stable CLI interface. Use with caution in scripts». apt сделан для человека в терминале, apt-get — для автоматики.

.dockerignore

Контекст сборки — это весь каталог, в котором лежит Dockerfile. По умолчанию всё его содержимое улетает демону. Это плохо, если у тебя там node_modules/ на гигабайт, .git/ на полгигабайта и .env с секретами.

Решение — файл .dockerignore рядом с Dockerfile:

node_modules
.git
.env
.env.*
dist
*.log
coverage

Синтаксис как у .gitignore. Файлы из ignore-листа не попадают в контекст и, что важнее, не могут быть скопированы COPY, даже если ты напишешь COPY .env /app/.

USER: не запускайся под root

По умолчанию контейнер бежит под root, uid 0 внутри namespace. Это уже гораздо безопаснее, чем root на хосте (его огораживают capabilities, seccomp, AppArmor), но всё равно атаковать такой контейнер проще, чем не-root.

# Если в базовом образе уже есть юзер node:
USER node
# Или создать вручную:
RUN addgroup -S app && adduser -S -G app app
USER app

После USER все RUN и финальный процесс пойдут под этим пользователем. Если приложение слушает порт ниже 1024, под не-root это не получится без --cap-add=NET_BIND_SERVICE (или используй порт выше 1024 и пробрасывай).

Секреты: не в ENV, не в ARG

Типичный соблазн:

ARG DB_PASSWORD
ENV DB_PASSWORD=$DB_PASSWORD

Не делай так. Значение ARG видно в docker history, а ENV — и в docker history, и в рантайме контейнера. Кто угодно сможет посмотреть:

docker history --no-trunc myimage
docker inspect myimage --format '{{.Config.Env}}'

И увидит твой пароль. Правильно:

Multi-stage build

Главный приём для тонких образов. Идея: собираем в одном базовом образе со всем тяжёлым (компилятор, dev-зависимости), копируем только артефакт в финальный лёгкий образ.

# stage 1: builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# stage 2: runtime
# в проде лучше пин по digest: node:22-alpine@sha256:...
# (комментарий обязательно отдельной строкой: в строке FROM он ломает сборку)
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Тонкий момент: в builder стоит npm ci со всеми зависимостями - иначе npm run build не запустится (нужны TypeScript, бандлер и прочий dev-инструмент). А в runtime-стадии ставим заново, но уже npm ci --omit=dev, то есть только прод-зависимости. Если бы мы вместо этого скопировали node_modules из builder, в финальный образ уехали бы и dev-deps, ровно то, от чего multi-stage и должен избавлять.

В итоге в финальный образ попадают только собранный код и runtime-зависимости. Без dev-deps, без TypeScript-компилятора, без .git. Часто разница в 10-30 раз: первый билд — сотни мегабайт, multi-stage — десятки.

Что важно запомнить

Каждая инструкция в Dockerfile превращается в слой. Кеш ломается на первой изменившейся строке: всё, что ниже, пересобирается с нуля. Поэтому редко меняющиеся вещи (зависимости) держим сверху, часто меняющиеся (код) — снизу.

CMD переопределяется аргументами docker run, ENTRYPOINT остаётся зафиксированным. Exec-форма ["..."] нужна, чтобы сигналы доходили до приложения, а не до sh.

По умолчанию процесс в контейнере идёт под root, и при побеге из контейнера это сразу root на хосте. Поэтому добавляй USER name всегда, когда можешь. Секреты в ENV/ARG остаются в docker history и docker inspect. Используй BuildKit-секреты на сборке, переменные среды на запуске.

Multi-stage даёт лёгкий прод-образ: тяжёлый builder отдельно, в финал едет только артефакт.

В следующем уроке — как хранить состояние: volumes, bind mounts, переменные окружения и ловушка с DB_HOST, на которой все спотыкаются.

Дрилл · вопросы как на собесе

Проверь себя без подсказок

Сформулируй ответ вслух или письменно, потом раскрой и честно отметь: знал или нет.

01

В чём разница между `ADD` и `COPY` в Dockerfile?

DOC-007
02

В чём разница между `CMD` и `ENTRYPOINT` в Dockerfile?

DOC-006
03

Перечисли best practices Dockerfile.

DOC-019
04

Что такое multi-stage builds и зачем они нужны?

DOC-018
05

Как уменьшить размер Docker-образа?

DOC-020

Прошли материал до конца? Отметьте урок пройденным.