Класична одноетапна збірка Dockerfile для сучасних фронтенд-застосунків призводить до створення роздутих, небезпечних та повільних образів. Якщо виконувати інсталяцію залежностей, компіляцію TypeScript та запуск сервера в межах єдиного образу, підсумковий артефакт неминуче зберігає у своїх шарах увесь файловий оверхед середовища розробки: вихідний код, компілятори, тимчасові кеші пакетного менеджера та допоміжні утиліти.
Технологія Multi-Stage Build (багатоетапна збірка) кардинально змінює цю парадигму. Вона розмежовує життєвий цикл контейнера на чотири строго детерміновані стадії:
base: спільна базова конфігурація операційної системи, фіксація стабільної версії Node.js та глобальна ініціалізація пакетного менеджера pnpm через Corepack.deps: виділений шар для інсталяції зовнішніх модулів. Вихідний код проєкту сюди навмисно не копіюється, щоб шар кешувався демоном Docker доти, доки не зміниться pnpm-lock.yaml.builder: контекст компіляції, де запускається процес pnpm build. На цьому етапі генерується ізольований артефакт output: 'standalone' та збирається клієнтська статика.runner: фінальний мінімалістичний контейнер, що містить виключно бінарник середовища Node.js, непривілейованого системного користувача та скомпільовані артефакти. Усі складальні ланцюжки та понад 90% залежностей фізично зникають з фінального образу.[Multi-Stage Конвеєр збірки]
┌───────────────┐
│ Stage: base │ ──► node:22-alpine / glibc-compat / pnpm setup
└───────┬───────┘
│
├──────────────────────────────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Stage: deps │ │ Stage: runner │
│ │ │ │
│ • package.json│ │ • non-root │
│ • lockfile │ │ nextjs:1001 │
│ • pnpm cache │ │ • Distroless │
└───────┬───────┘ │ або Alpine │
│ node_modules └───────▲───────┘
▼ │
┌───────────────┐ │ Копіювання
│Stage: builder │ │ артефактів
│ │ │
│ • Вихідний код│ │
│ • pnpm build │ ─────────────────────────┘
│ • standalone │ ──► .next/standalone + static + public
└───────────────┘У поєднанні з підсистемою Docker BuildKit багатоетапні збірки відкривають доступ до технології Cache Mounts (--mount=type=cache). Вона дозволяє монтувати глобальний кеш пакетів pnpm store безпосередньо з хост-машини або CI-раннера без його фіксації у постійних шарах контейнера, прискорюючи повторні збірки в CI/CD у 5–10 разів.
Ментальна модель високоефективного контейнера будується навколо принципу найменших привілеїв (Principle of Least Privilege) та мінімізації поверхні атаки (Attack Surface Reduction).
Контейнер не є повноцінною віртуальною машиною — це ізольований процес на хостовому ядрі Linux, обмежений механізмами namespaces та cgroups. Якщо всередині контейнера процес запущено під користувачем root (UID 0), будь-яка вразливість виходу за межі ізоляції (Container Breakout) або небезпечне монтування сокета (-v /var/run/docker.sock) надає зловмиснику повний root-контроль над фізичним сервером.
Для гарантування надійного захисту в рантаймі застосовуються два інженерні стандарти:
UID=1001 та GID=1001 (nextjs:nodejs), позбавленого прав на зміну системних файлів ОС та можливості підвищення привілеїв (no-new-privileges).gcr.io/distroless/nodejs22), що містять виключно середовище виконання Node.js та критично важливі динамічні системні бібліотеки (glibc, libssl). В образах Distroless повністю відсутні:
/bin/sh, /bin/bash).apt, apk).curl, wget, nc).[Порівняння структури традиційного образу та Distroless]
Традиційний образ (node:22 / Ubuntu / Alpine):
┌─────────────────────────────────────────────────────────────┐
│ Застосунок (server.js, node_modules) │
├─────────────────────────────────────────────────────────────┤
│ Node.js Runtime (V8, libuv) │
├─────────────────────────────────────────────────────────────┤
│ Системні утиліти: sh, bash, apk, curl, tar, find, coreutils │ ◄── Вектор для RCE
├─────────────────────────────────────────────────────────────┤
│ Системні бібліотеки: glibc/musl, libcrypto, ca-certs │
└─────────────────────────────────────────────────────────────┘
Образ Distroless (gcr.io/distroless/nodejs22):
┌─────────────────────────────────────────────────────────────┐
│ Застосунок (server.js, node_modules) │
├─────────────────────────────────────────────────────────────┤
│ Node.js Runtime (V8, libuv) │
├─────────────────────────────────────────────────────────────┤
│ Системні бібліотеки: glibc, libssl, ca-certificates │
└─────────────────────────────────────────────────────────────┘
(Командні оболонки та системні бінарники фізично видалені)При спробі експлуатації вразливості типу Remote Code Execution (RCE) через ін'єкцію системних команд атака миттєво зазнає краху: ядро Linux повертає помилку exec: "sh": executable file not found in $PATH.
Ключові сутності оптимізації контейнерів:
Аудит безпеки за стандартами PCI-DSS суворо забороняє наявність компіляторів (GCC, Python, Make) та пакетних менеджерів у продакшені. Використання Distroless у тандемі з non-root користувачем зводить звіти сканерів вразливостей Trivy до нуля (0 Critical, 0 High), забезпечуючи проходження автоматичних перевірок безпеки перед деплоєм.
За умов, коли інженерна команда виконує по 40–60 релізів на день, BuildKit Cache Mounts скорочує час кроку pnpm install із 2.5 хвилин до 4 секунд. Кеш пакетів зберігається на раннері збірки та інвалідується лише при реальній зміні хешу в pnpm-lock.yaml.
При розгортанні Next.js на граничних серверах з обмеженим обсягом SSD стиснення фінального образу до 70–90 МБ істотно знижує навантаження на дискову підсистему та мінімізує мережевий оверхед під час завантаження шарів із корпоративного Docker Registry.
Для досягнення максимальної швидкості збірки та надійного захисту складальний конвеєр проєктується за суворим стандартом:
Фіксація базового дистрибутива, змінних середовища PNPM_HOME та активація Corepack.
Монтування BuildKit кешу в /pnpm/store та запуск pnpm install із прапорцем --frozen-lockfile.
Копіювання node_modules та вихідного коду, компіляція проєкту pnpm build із кешем .next/cache.
Створення non-root користувача 1001:1001, перенесення артефактів та старт у Distroless/Alpine оточенні.
Пакетний менеджер pnpm зберігає всі файли залежностей в єдиному контентно-адресованому сховищі (store-dir). Щоб використати цю перевагу всередині Docker:
PNPM_HOME="/pnpm".--mount=type=cache,id=pnpm,target=/pnpm/store.pnpm config set store-dir /pnpm/store.За такого підходу під час наступних збірок завантажуються лише оновлені пакети, а не все дерево заново.
# syntax=docker/dockerfile:1.7
# ==============================================================================
# 1. СТАДІЯ: BASE
# Єдиний базовий шар ОС, налаштування змінних оточення та Corepack
# ==============================================================================
FROM node:22-alpine AS base
ENV NODE_ENV=production \
PNPM_HOME="/pnpm" \
PATH="$PNPM_HOME:$PATH" \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0
WORKDIR /app
# Встановлення libc6-compat для коректної компіляції нативних аддонів (Sharp, SWC)
RUN apk add --no-cache libc6-compat && \
corepack enable pnpm
# ==============================================================================
# 2. СТАДІЯ: DEPS
# Встановлення залежностей з ізоляцією від коду та монтуванням кешу
# ==============================================================================
FROM base AS deps
WORKDIR /app
# Копіюємо ВИКЛЮЧНО маніфести пакетів для збереження кешу шару
COPY package.json pnpm-lock.yaml* ./
# Монтуємо постійний кеш хоста у сховище pnpm store.
# Кеш не потрапляє у фінальний образ, але зберігається між збірками!
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile --prod=false
# ==============================================================================
# 3. СТАДІЯ: BUILDER
# Компіляція Next.js застосунку та генерація standalone-зрізу
# ==============================================================================
FROM base AS builder
WORKDIR /app
# Копіюємо встановлені модулі зі стадії deps
COPY --from=deps /app/node_modules ./node_modules
# Копіюємо вихідний код застосунку
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
# Монтуємо кеш компілятора .next/cache для прискорення перезбірок у CI
RUN --mount=type=cache,id=nextcache,target=/app/.next/cache \
pnpm build
# ==============================================================================
# 4. СТАДІЯ: RUNNER (Варіант A: Захищений Alpine Linux)
# Мінімальний рантайм, non-root користувач та копіювання артефактів
# ==============================================================================
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0" \
NEXT_TELEMETRY_DISABLED=1
# Створення системної групи та користувача з фіксованими UID/GID 1001
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Підготовка папки для інкрементального ISR-кешу з правами для nextjs
RUN mkdir -p .next/cache && \
chown -R nextjs:nodejs /app
# 1. Копіювання публічної статики
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# 2. Копіювання скомпільованого автономного сервера
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 3. Копіювання клієнтських хешованих бандлів
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Скидання привілеїв: перехід у контекст непривілейованого користувача
USER nextjs
EXPOSE 3000
# Запуск ізольованого сервера Node.js
CMD ["node", "server.js"]
# ==============================================================================
# АЛЬТЕРНАТИВА: RUNNER DISTROLESS (Максимальна безпека без шелу)
# Для збірки вкажіть: docker build --target=runner-distroless -t app:distroless .
# ==============================================================================
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner-distroless
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0" \
NEXT_TELEMETRY_DISABLED=1
# У Distroless nonroot користувач (UID 65532) уже вбудований за замовчуванням
COPY --from=builder --chown=nonroot:nonroot /app/public ./public
COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./
COPY --from=builder --chown=nonroot:nonroot /app/.next/static ./.next/static
USER nonroot
EXPOSE 3000
# У Distroless оболонка /bin/sh відсутня, точка входу вказується строго в exec-формі:
CMD ["server.js"]Розробник пише в Dockerfile COPY . . перед виконанням pnpm install. У результаті будь-яка зміна у файлі стилів чи компоненті інвалідує кеш шару Docker, і збирач повторно завантажує гігабайти залежностей з мережі під час кожної збірки.
Причина: Прагнення скоротити кількість рядків у Dockerfile.
Реальність у продакшені: Час збірки в CI/CD зростає з 30 секунд до 5–7 хвилин на кожен пуш, блокуючи конвеєр інтеграції.
# ❌ АНТИПАТЕРН: Копіювання всіх файлів проєкту перед встановленням пакетів
COPY . .
RUN pnpm install
# ✅ ПРАВИЛЬНО: Ізоляція маніфесту пакетів в окремий кешований шар
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .У фінальному шарі runner відсутня інструкція USER nextjs, і процес node server.js виконується з правами суперкористувача.
Причина: Небажання вирішувати проблеми з правами доступу (EACCES: permission denied) до файлів кешу або логів.
Реальність у продакшені: Порушення стандартів безпеки (CIS Docker Benchmark). У разі виявлення вразливості в сторонній npm-залежності зловмисник може перезаписати критичні системні файли або скомпрометувати хостову ОС за наявності спільних Volume-монтувань.
# ❌ ВРАЗЛИВІСТЬ: Процес виконується від root
CMD ["node", "server.js"]
# ✅ ПРАВИЛЬНО: Створення системного користувача та явний перехід
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 nextjs
USER nextjs
CMD ["node", "server.js"]В образі на базі gcr.io/distroless/nodejs22 інструкція запуску вказана в shell-синтаксисі: CMD node server.js замість exec-масиву CMD ["server.js"].
Причина: Розробники не розрізняють Shell Form (CMD command arg) та Exec Form (CMD ["executable", "param"]).
Реальність у продакшені: Під час старту контейнер миттєво падає з системною помилкою: container_linux.go: starting container process caused: exec: "/bin/sh": stat /bin/sh: no such file or directory. В образі Distroless немає шелу, тому Docker не може викликати неявний /bin/sh -c "node server.js".
# ❌ ПОМИЛКА: Shell-форма вимагає /bin/sh
CMD node server.js
# ✅ ПРАВИЛЬНО: Сувора Exec-форма JSON-масиву
CMD ["server.js"]При виклику сторінок з revalidate сервер у продакшені повертає помилку EACCES: permission denied, open '.next/cache/fetch-cache/...' і припиняє віддавати оновлені дані.
Причина: Копіювання артефактів від імені root із наступним перемиканням на USER nextjs, через що директорія .next належить root і доступна лише для читання.
Реальність у продакшені: Фонові задачі генерації сторінок (ISR) аварійно завершуються при спробі записати оновлений контент на диск.
# ❌ ПОМИЛКА: Папка створена root, у nextjs немає прав на запис
USER nextjs
CMD ["node", "server.js"]
# ✅ ПРАВИЛЬНО: Попереднє створення папки кешу та призначення прав власності
RUN mkdir -p .next/cache && chown -R nextjs:nodejs /app/.next
USER nextjs
CMD ["node", "server.js"]Додавання директиви # syntax=docker/dockerfile:1.7 першим рядком для увімкнення розширеного кешування.
Ізоляція package.json та lockfile від вихідного коду для захисту шарів кешу.
Монтування /pnpm/store та .next/cache через --mount=type=cache для миттєвого встановлення модулів.
Створення непривілейованого користувача 1001:1001 та перехід на Distroless/Alpine оточення.