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"]
Сверху вниз:
- Базовый образ — официальный node на alpine.
- Все последующие команды работают из
/appвнутри образа. - Копируем только манифесты зависимостей.
- Ставим зависимости.
- Копируем остальной код.
- Переключаемся с root на пользователя
node. - По умолчанию запускается
node server.js.
Что делает каждая инструкция
| Инструкция | Что делает |
|---|---|
FROM image:tag | базовый образ. Обязательная первая строка. Можно несколько (multi-stage). |
WORKDIR /path | cd для всех последующих инструкций и для дефолтного запуска. |
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
Тут все спотыкаются. Три рабочих варианта.
- Только
CMD ["node", "server.js"]— удобно, легко переопределить:docker run myimg shзапустит shell вместо node. - Только
ENTRYPOINT ["node", "server.js"]— жёстко закрепляет команду.docker run myimg --port 3000подставит--port 3000как аргументы кnode server.js. - Оба вместе — типовой паттерн:
ENTRYPOINT ["node"]+CMD ["server.js"]. Запуститсяnode server.js. Если переопределить:docker run myimg --inspect- запустится
node --inspect.
- запустится
Используй 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 миллисекундами на пересборку.
Когда меняешь только код (но не 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}}'
И увидит твой пароль. Правильно:
- Секреты на этапе сборки — через
RUN --mount=type=secret(BuildKit). - Секреты в рантайме — через
-e KEY=VALприdocker run, или через Docker secrets / внешний secret manager.
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, на которой все спотыкаются.
Дрилл · вопросы как на собесе
Проверь себя без подсказок
Сформулируй ответ вслух или письменно, потом раскрой и честно отметь: знал или нет.
В чём разница между `ADD` и `COPY` в Dockerfile?
DOC-007В чём разница между `CMD` и `ENTRYPOINT` в Dockerfile?
DOC-006Перечисли best practices Dockerfile.
DOC-019Что такое multi-stage builds и зачем они нужны?
DOC-018Как уменьшить размер Docker-образа?
DOC-020Прошли материал до конца? Отметьте урок пройденным.
Прогресс сохранён в этом браузере
Почистишь историю или откроешь с телефона, и отметки пропадут. Оставь почту, они переедут в аккаунт. Пароль придумывать не нужно.
Готово. теперь в аккаунте: прогресс виден с любого устройства. Мой прогресс