Dockerfile — это текстовый декларативный манифест, содержащий последовательный набор инструкций для сборки неизменяемого образа (Docker Image). Каждая инструкция в Dockerfile (FROM, COPY, RUN) генерирует отдельный неизменяемый слой файловой системы (Image Layer), сохраняемый в локальном кэше движка сборки (BuildKit).
Сборка образа начинается с отправки так называемого контекста сборки (Build Context) — файлов из локальной рабочей директории проекта — демону Docker. При сборке критически важно соблюдать архитектуру послойного кэширования: если входные данные слоя не изменились (контрольная сумма файлов совпадает с предыдущей сборкой), Docker берет готовый слой из кэша мгновенно (статус CACHED). Но как только один слой инвалидируется, все последующие нижележащие инструкции пересобираются с нуля.
Понимание анатомии Dockerfile позволяет инженеру сократить время сборки в CI/CD с 10 минут до 15 секунд, уменьшить размер итогового контейнера с 1.5 ГБ до 150 МБ и исключить утечку конфиденциальных данных и локальных артефактов (node_modules) в продакшен-образ.
Рассмотрим иерархию слоев и механизм инвалидации кэша при сборке типового приложения Node.js:
+-------------------------------------------------------------------------+
| ИЕРАРХИЯ СЛОЕВ И ИНВАЛИДАЦИЯ КЭША DOCKER |
+-------------------------------------------------------------------------+
| Инструкция Dockerfile: Состояние кэша: |
| |
| FROM node:24-alpine [CACHED] (Базовый образ скачан 1 раз) |
| │ |
| WORKDIR /app [CACHED] (Создана директория) |
| │ |
| COPY package.json pnpm-lock.yaml [CACHED] (Хэш lock-файла НЕ изменился)|
| │ |
| RUN pnpm install [CACHED] (Пропуск! Пакеты взяты из кэша)
| │ |
| ──┼─────────────────────────────────────────────────────────────────── |
| │ (Разработчик изменил 1 строчку в src/index.ts -> Хэш изменился!) |
| ▼ |
| COPY . . [INVALIDATED] -> Новая сборка слоя |
| │ |
| CMD ["node", "server.js"] [BUILT] -> Обновление метаданных|
+-------------------------------------------------------------------------+Если по ошибке написать COPY . . до вызова pnpm install, то любое микроизменение в исходном коде проекта (src/index.ts) инвалидирует кэш на этапе копирования, заставляя Docker повторно выкачивать гигабайты npm-пакетов из интернета при каждой сборке.
Второй фундаментальный аспект — Exec-форма vs Shell-форма инструкции CMD:
Shell-форма (CMD node server.js): запускает командную оболочку /bin/sh -c "node server.js". Оболочка получает PID 1 и не пересылает входящие сигналы SIGTERM процессу Node.js, из-за чего graceful shutdown становится невозможным.
Exec-форма (CMD ["node", "server.js"]): запускает бинарник напрямую без промежуточного shell. Node.js становится процессом с PID 1 и мгновенно получает сигналы операционной системы.
CLI отсекает локальные node_modules, секреты (.env) и служебную директорию .git, упаковывая только целевые файлы в контекст сборки.
Движок BuildKit сверяет хэш базового образа node:24-alpine с локальным хранилищем и при необходимости скачивает недостающие слои.
Копируются только манифесты пакетов (package.json, lockfile) и выполняется pnpm install с созданием слоя зависимостей в кэше.
Копируется прикладной код, переключается пользователь (USER node) и объявляется точка входа в формате exec-массива CMD.
Ядро создает namespaces и cgroups, биндит хостовый порт на порт контейнера (-p 3000:3000) и стартует процесс Node.js.
Почему инструкция `COPY package.json pnpm-lock.yaml ./` и команда `RUN pnpm install` должны выполняться ДО инструкции `COPY . .`?
node (UID 1000) исключает возможность побега из контейнера на хост в случае RCE-уязвимости в прикладном коде.node:24-alpine (размером менее 150 МБ) вместо полных дистрибутивов Ubuntu (более 1 ГБ) минимизирует количество предустановленных уязвимых пакетов (CVE).--rm и монтированием портов гарантирует чистоту хостовой системы без необходимости засорять глобальную ОС различными версиями Node.js.Перед созданием Dockerfile в корне проекта создается файл .dockerignore. Он предотвращает случайную отправку локально установленных пакетов, секретов и служебных файлов в демон сборки:
# Исключаем локальные зависимости (они должны собираться начисто внутри образа)
node_modules
npm-debug.log*
pnpm-debug.log*
# Исключаем секреты и переменные окружения
.env
.env.*
!.env.example
# Исключаем артефакты контроля версий и IDE
.git
.gitignore
.vscode
.idea
# Временные каталоги сборки и кэш тестов
dist
build
.coverage
*.tsbuildinfo# 1. Выбор минимального базового дистрибутива на базе Alpine Linux
FROM node:24-alpine AS base
# 2. Установка системных зависимостей (libc6-compat часто нужен для нативных бинарников)
RUN apk add --no-cache libc6-compat
# 3. Активация пакетного менеджера pnpm через встроенный Corepack
RUN corepack enable pnpm
# 4. Назначение рабочей изолированной директории
WORKDIR /app
# 5. Слой зависимостей: копируем ТОЛЬКО манифесты пакетов
COPY package.json pnpm-lock.yaml ./
# 6. Установка зависимостей с жесткой фиксацией lock-файла
RUN pnpm install --frozen-lockfile --prod=false
# 7. Слой исходного кода: копируем оставшиеся файлы проекта
COPY . .
# 8. Сборка TypeScript-кода в чистый JavaScript (если используется сборщик)
# RUN pnpm run build
# 9. Принцип наименьших привилегий: переключаемся с root на встроенного пользователя node
USER node
# 10. Декларация используемого порта (документирование для разработчиков и оркестратора)
EXPOSE 3000
# 11. Переменные окружения рантайма
ENV NODE_ENV=production
ENV PORT=3000
# 12. Exec-форма команды запуска: Node.js становится процессом PID 1
CMD ["node", "server.js"]Управление контейнером в терминале осуществляется с помощью набора стандартных команд:
# 1. Сборка образа с присвоением тега my-node-app версии 1.0.0
# Точка в конце означает, что контекст сборки берется из текущей директории
docker build -t my-node-app:1.0.0 .
# 2. Проверка созданного образа в локальном хранилище
docker images | grep my-node-app
# 3. Запуск контейнера в фоновом режиме (демон):
# -d : запуск в фоне (detached mode)
# -p 3000:3000 : проброс порта (порт_хоста:порт_контейнера)
# --name : читаемое имя контейнера для управления
# --rm : автоматическое удаление контейнера после его остановки
docker run -d \
-p 3000:3000 \
--name app-instance \
--memory="512m" \
--rm \
my-node-app:1.0.0
# 4. Просмотр активных запущенных контейнеров
docker ps
# 5. Просмотр потоковых логов приложения в реальном времени (аналог tail -f)
docker logs -f app-instance
# 6. Вход внутрь работающего контейнера для интерактивной отладки
docker exec -it app-instance sh
# 7. Штатная остановка контейнера (Docker отправляет SIGTERM, ждет 10с, затем SIGKILL)
docker stop app-instanceФлаг -p 8080:3000 указывает демону Docker создать правило трансляции сетевых адресов (DNAT) в подсистеме iptables / nftables ядра Linux:
Входящий запрос от клиента:
http://server-ip:8080 ──► [Ядро хоста: iptables DNAT] ──► [Виртуальный интерфейс veth] ──► [Контейнер eth0: порт 3000]-p, порт останется изолированным внутри пространства имен NET контейнера и будет недоступен извне.EXPOSE 3000 в Dockerfile не открывает порт наружу автоматически. Она служит исключительно машиночитаемой документацией.BuildKit преобразует текст Dockerfile в направленный ациклический граф операций (Low-Level Builder).
Независимые этапы сборки (например, компиляция фронтенда и скачивание пакетов) могут выполняться параллельно.
Для каждой инструкции вычисляется криптографический хэш sha256 от команды и содержимого файлов.
При совпадении хэшей берется существующий снапшот без расхода ресурсов CPU.
Команда pnpm install исполняется внутри изолированного временного контейнера сборщика.
Все созданные и измененные файлы фиксируются в виде нового неизменяемого слоя tar/diff.
Слои объединяются в итоговый OCI-манифест, образу присваивается имя my-node-app:1.0.0 и обновляется локальный индекс.
Итоговый образ готов к отправке в Docker Registry командой docker push.
Разработчик добавил строку `EXPOSE 3000` в Dockerfile и запустил контейнер командой `docker run -d my-app`. Сможет ли внешний клиент открыть сайт по адресу http://localhost:3000?
Если в проекте нет файла .dockerignore, команда docker build передаст папку node_modules с хоста в контекст сборки.
Это приводит к двум фатальным проблемам:
invalid ELF header.
Решение: Всегда добавляйте node_modules в файл .dockerignore.Запись в виде строки:
# ПЛОХО: Shell-форма
CMD node server.jsзапускает /bin/sh в качестве PID 1. При вызове docker stop сигнал SIGTERM перехватывается оболочкой sh и не доходит до процесса Node.js. Контейнер зависает на 10 секунд и жестко убивается ядром по SIGKILL.
Решение: Всегда используйте Exec-форму JSON-массива:
# ХОРОШО: Exec-форма
CMD ["node", "server.js"]По умолчанию все команды внутри Dockerfile выполняются от суперпользователя root (UID 0). Если в приложении возникнет уязвимость выполнения произвольного кода, атакующий получит права суперпользователя.
Решение: Перед вызовом CMD всегда объявляйте непривилегированного пользователя: USER node.
Запуск команды pnpm install без флага исключения dev-зависимостей приводит к тому, что в финальный образ попадают компиляторы TypeScript, тестовые фреймворки (Vitest, Jest) и линтеры ESLint. Это раздувает размер образа на сотни мегабайт и увеличивает риск уязвимостей.
Решение: Для сборки продакшен-образов используйте многоэтапную сборку (Multi-stage build) или флаг --prod.