Продвинутая сборка: Оптимизация и Multi-stage

Курс посвящен искусству создания минималистичных и быстрых образов. Вы научитесь управлять кэшированием слоев через порядок инструкций и освоите технологию Multi-stage build для разделения среды сборки и среды исполнения.

Порядок имеет значение: Оптимизация кэша через слои

Порядок имеет значение: Оптимизация кэша через слои

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

В прошлом курсе мы выяснили, что каждая инструкция в Dockerfile (например, RUN, COPY) создает новый слой файловой системы. Сегодня мы разберем, как Docker принимает решение: пересобрать этот слой заново или взять готовый из кэша, и как порядок строк в файле влияет на скорость сборки.

Механика кэширования: слои как контрольные точки

Когда вы запускаете docker build, Docker не выполняет инструкции слепо. Перед выполнением каждой строки он задает себе вопрос: «Делал ли я точно такую же операцию в точно таких же условиях ранее?».

Если ответ «да», происходит Cache hit (попадание в кэш) — Docker мгновенно берет готовый слой с диска. В логах сборки это отображается пометкой CACHED. Если ответ «нет», происходит Cache miss (промах кэша) — Docker честно выполняет команду, тратя время и ресурсы, а затем сохраняет результат как новый слой.

Математически время сборки образа можно выразить так: Ttotal=i=1ntiT_{total} = \sum_{i=1}^{n} t_i Где TtotalT_{total} — общее время сборки, nn — количество слоёв, а tit_i — время сборки конкретного слоя ii. Если слой берется из кэша, его ti0t_i \approx 0. Наша цель — сделать так, чтобы при повторных сборках как можно больше слоёв имели время, близкое к нулю.

Для инструкций RUN Docker просто сравнивает текст команды. RUN apt-get install nginx сегодня и завтра — это одна и та же строка, значит, можно использовать кэш. Для инструкций COPY и ADD Docker вычисляет контрольную сумму (хэш) копируемых файлов. Если содержимое хотя бы одного файла изменилось (та самая исправленная опечатка), хэш меняется, и Docker понимает: кэш использовать нельзя.

Эффект домино: как ломается кэш

Самое важное правило кэширования в Docker, которое нужно запомнить раз и навсегда: если кэш инвалидирован (сломан) на определенном слое, все последующие слои также будут пересобраны с нуля.

Слои зависят друг от друга. Слой номер 4 опирается на состояние файловой системы из слоя номер 3. Если слой 3 изменился, Docker не может гарантировать, что команда из слоя 4 выполнится с тем же результатом. Поэтому при Cache miss на NN-ном шаге, шаги N+1N+1, N+2N+2 и так далее автоматически получают Cache miss, даже если сами команды в них не менялись.

Это и есть причина 10-минутного ожидания из-за одной опечатки: изменение файла сломало кэш на инструкции COPY, а за ней по цепочке пересобрались все тяжелые инструкции RUN, стоявшие ниже.

Главное правило: от редкого к частому

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

Идеальная структура слоёв выглядит так:

  1. Базовый образ и системные настройки (FROM, ENV, WORKDIR). Меняются крайне редко (раз в несколько месяцев при обновлении версий).
  2. Системные зависимости (RUN apt-get ...). Меняются редко (при добавлении новых утилит).
  3. Зависимости приложения (копирование файлов конфигурации пакетов и их установка). Меняются периодически (когда разработчик добавляет новую библиотеку).
  4. Исходный код приложения (COPY . .). Меняется постоянно (при каждом коммите).

Если исходный код находится в самом низу Dockerfile, его изменение сломает только последний слой. Все тяжелые этапы установки библиотек, находящиеся выше, будут мгновенно взяты из кэша.

Практика: Оптимизация сборки Node.js

Рассмотрим классическую ошибку новичков на примере приложения Node.js.

Неоптимизированный Dockerfile:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "app.js"]

Что здесь происходит? Инструкция COPY . . копирует в образ всё содержимое текущей папки: и файл зависимостей package.json, и сам код app.js. Если мы меняем логику в app.js, хэш файлов для COPY меняется. Кэш ломается на 3-й строке. Следовательно, 4-я строка (RUN npm install) выполняется заново. Docker будет скачивать все библиотеки из интернета при каждом изменении кода.

Оптимизированный Dockerfile:

FROM node:18
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
CMD ["node", "app.js"]

Мы разделили копирование на два этапа. Сначала мы копируем только package.json — файл, в котором перечислены нужные библиотеки. Затем запускаем npm install. И только после того, как библиотеки установлены, мы копируем остальной исходный код (COPY . .).

Теперь, если мы изменим app.js, кэш сломается только на втором COPY. Инструкция RUN npm install останется нетронутой, потому что файл package.json не менялся, и кэш для него (и последующего RUN) сохранился. Время сборки сокращается с минут до долей секунды.

Понимание того, как порядок команд взаимодействует с кэшем — это первый шаг к созданию production-ready образов. В следующей статье мы рассмотрим, как управлять параметрами сборки динамически, не меняя сам код Dockerfile, используя глобальные аргументы.

Глобальные настройки: Использование ARG и парсерных директив

Глобальные настройки: Использование ARG и парсерных директив

В прошлой главе мы научились грамотно располагать слои, чтобы Docker переиспользовал кэш и собирал образы за секунды. Но представьте ситуацию: у вас есть отлично оптимизированный Dockerfile для Node.js приложения, и вдруг команда решает протестировать код на новой версии платформы.

Если версия жестко прописана как FROM node:18, вам придется вручную редактировать файл. А если нужно собрать три разных образа для версий 16, 18 и 20 в CI/CD пайплайне? Дублировать файлы — плохая идея. Нам нужен способ сделать Dockerfile динамическим, передавая ему параметры снаружи прямо в момент сборки.

ARG: Переменные времени сборки

Для передачи параметров внутрь процесса сборки существует инструкция ARG. Она работает как аргумент функции: вы объявляете её в Dockerfile, а конкретное значение передаете через терминал.

Синтаксис выглядит так:

# Объявляем переменную со значением по умолчанию
ARG APP_VERSION=1.0.0

# Используем её при скачивании конкретного релиза
RUN curl -O https://example.com/app-${APP_VERSION}.tar.gz

При запуске сборки вы можете переопределить это значение с помощью флага --build-arg:

docker build --build-arg APP_VERSION=2.1.0 -t myapp:2.1.0 .

Главная ловушка: ARG против ENV

Из базового курса вы уже знаете про инструкцию ENV, которая задает переменные окружения. Начинающие инженеры часто путают ARG и ENV, так как обе инструкции позволяют использовать синтаксис ${VAR_NAME}. Разница заключается в их жизненном цикле.

ARG живет только в момент выполнения docker build. Как только образ собран, все значения ARG исчезают. ENV вшивается в метаданные образа и продолжает жить внутри запущенного контейнера (при docker run).

Если вашему приложению нужен токен для подключения к базе данных во время работы — это ENV. Если вам нужен токен только для того, чтобы скачать приватный пакет во время сборки — это ARG.

Более того, вы можете использовать ARG для динамического формирования ENV:

# Получаем при сборке
ARG BUILD_ENV=development
# Сохраняем в образ для рантайма
ENV NODE_ENV=${BUILD_ENV}

Глобальный ARG и парадокс инструкции FROM

Теперь вернемся к нашей исходной задаче: как параметризовать саму инструкцию FROM?

Исторически FROM обязана быть первой инструкцией в Dockerfile. Но как передать ей переменную, если переменные объявляются через ARG? Docker делает единственное исключение из правил: инструкция ARG — это единственная команда, которая может стоять до FROM.

ARG NODE_VERSION=18
FROM node:${NODE_VERSION}

WORKDIR /app
COPY . .

Это решает проблему базового образа. Но здесь кроется неочевидная архитектурная особенность Docker, связанная с областями видимости (scope).

Инструкция FROM начинает новый этап сборки (build stage). Любой ARG, объявленный до FROM, считается глобальным и находится вне этого этапа. Если вы попытаетесь использовать ${NODE_VERSION} где-то ниже по тексту, она окажется пустой.

Чтобы использовать глобальную переменную внутри этапа сборки, её нужно «пробросить» внутрь, объявив ARG еще раз, но уже без значения:

# Глобальная область видимости
ARG NODE_VERSION=18

FROM node:${NODE_VERSION}
# Локальная область видимости этапа
ARG NODE_VERSION

# Теперь переменная доступна внутри образа
RUN echo "Сборка на базе Node.js версии ${NODE_VERSION}"

Парсерные директивы: Управление самим Docker

Мы выяснили, что ARG может стоять перед FROM. Но есть конструкции, которые должны стоять даже перед глобальным ARG. Это парсерные директивы (parser directives).

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

Директива syntax

Самая важная директива в современном Docker — это # syntax. Она позволяет использовать внешнюю, более новую версию сборщика (BuildKit) вместо той, что встроена в ваш текущий Docker-демон.

# syntax=docker/dockerfile:1
ARG NODE_VERSION=18
FROM node:${NODE_VERSION}

Указав # syntax=docker/dockerfile:1, вы гарантируете, что ваш файл будет парситься по самым современным правилам, даже если сборка запускается на сервере со старой версией Docker. Это открывает доступ к продвинутым функциям вроде монтирования кэша и секретов, которые мы разберем в будущих главах.

Директива escape

Вторая по популярности директива — # escape. По умолчанию в Dockerfile символ экранирования и переноса строки — это обратный слеш \. Это отлично работает в Linux, но создает проблемы при сборке Windows-контейнеров, где \ используется в путях к файлам (например, C:\app\config).

Директива позволяет изменить этот символ, например, на обратную кавычку (backtick):

# escape=`
FROM mcr.microsoft.com/windows/servercore:ltsc2022
COPY testfile.txt c:\temp\testfile.txt

Итог

Теперь наш Dockerfile больше не является жестким скриптом. Используя ARG и парсерные директивы, мы превратили его в гибкий шаблон, который адаптируется под разные окружения через CLI-аргументы.

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

Проблема «тяжелых» образов: Почему компиляторы не нужны в продакшне

Проблема «тяжелых» образов: Почему компиляторы не нужны в продакшне

Вы написали микросервис на языке Go. Исходный код занимает пару мегабайтов. Вы компилируете его, и на выходе получается исполняемый бинарный файл размером всего 15 МБ. Затем вы пишете Dockerfile, используете базовый образ golang:1.20, копируете код, запускаете сборку и проверяете размер готового контейнера.

Он весит 850 МБ.

Откуда взялись лишние 835 мегабайтов, если вашему приложению для работы нужен только один файл размером 15 МБ? В этой статье мы разберем анатомию «тяжелых» образов и выясним, почему перенос среды сборки в production — это не просто трата дискового пространства, но и серьезная угроза безопасности.

Анатомия «толстого» контейнера

Чтобы превратить исходный код в готовое приложение, нам нужны инструменты. Для Go это компилятор, стандартная библиотека, утилиты для форматирования и тестирования. Для C++ — это GCC или Clang, утилита make, заголовочные файлы (headers). Для Java — тяжеловесный JDK (Java Development Kit) и сборщики вроде Maven или Gradle.

Когда мы указываем в Dockerfile инструкцию FROM golang:1.20 или FROM node:18, мы скачиваем не просто ядро для запуска приложения. Мы скачиваем полноценную операционную систему (чаще всего на базе Debian или Ubuntu), в которую заботливо предустановлен весь арсенал разработчика:

  1. Системные библиотеки и пакетные менеджеры (apt).
  2. Сетевые утилиты (curl, wget, ssh).
  3. Системы контроля версий (git).
  4. Компиляторы и интерпретаторы.
  5. Исходный код стандартных библиотек языка.

Все эти инструменты жизненно необходимы на этапе выполнения инструкции RUN go build или RUN npm install. Без них код не соберется. Но как только бинарный файл создан, компилятор свою работу выполнил. С этого момента он превращается в мертвый груз.

Цена лишних мегабайтов

Раздутый размер образа бьет по инфраструктуре сразу с нескольких сторон.

Скорость масштабирования. Представьте, что нагрузка на ваш сервис резко возросла, и оркестратор (например, Kubernetes) принимает решение запустить 10 новых экземпляров приложения на новых серверах. Если образ весит 15 МБ, сервера скачают его из registry за доли секунды. Если образ весит 850 МБ, скачивание суммарных 8.5 ГБ по сети займет время. В условиях пиковой нагрузки эти секунды или минуты ожидания означают потерянных пользователей.

Стоимость хранения и трафика. Облачные провайдеры тарифицируют как объем занятого места в Container Registry, так и исходящий сетевой трафик. Хранение десятков версий гигабайтных образов быстро превращается в заметную статью расходов.

Поверхность атаки: подарок для хакера

Проблема размера — это вопрос денег и времени. Но наличие компиляторов в production-образе — это вопрос выживания бизнеса. Здесь мы сталкиваемся с важнейшим концептом информационной безопасности.

Поверхность атаки (Attack surface) — это совокупность всех точек приложения и его окружения, через которые злоумышленник может попытаться внедрить вредоносный код, изменить данные или нарушить работу системы. Чем больше инструментов доступно внутри системы, тем шире поверхность атаки.

Ни один код не идеален. Допустим, в вашем приложении нашлась уязвимость, позволяющая злоумышленнику выполнить произвольную команду в системе (RCE — Remote Code Execution).

Если ваше приложение крутится в минималистичном контейнере, где есть только сам бинарный файл приложения и базовые системные вызовы, хакер окажется в «пустой комнате». Он не сможет ни скачать сторонний скрипт (нет curl), ни запустить его (нет bash или python), ни скомпилировать эксплойт на месте (нет gcc).

Но если вы отправили в production образ сборки, вы сами предоставили хакеру идеальную рабочую среду. Обнаружив уязвимость, он воспользуется встроенным curl для скачивания криптомайнера, применит tar для его распаковки и запустит его в фоне. Или, что еще хуже, использует предустановленный git и компилятор, чтобы собрать специфичный эксплойт прямо на вашем сервере, идеально подогнав его под ядро хост-машины.

Архитектурный парадокс

Мы оказываемся в тупике, который диктует нам линейная природа базового Dockerfile:

Характеристика Среда сборки (Build) Среда выполнения (Runtime)
Потребность Нужны компиляторы, исходный код, SDK, утилиты. Нужен только готовый артефакт (бинарник) и минимальная ОС.
Размер Сотни мегабайт или гигабайты. Десятки мегабайт.
Безопасность Не критична (работает изолированно в CI/CD). Критична (смотрит в интернет, требует минимальной поверхности атаки).

Если мы возьмем легковесный базовый образ (например, alpine весом в 5 МБ), мы не сможем скомпилировать наше приложение — Dockerfile упадет на шаге RUN. Если мы возьмем тяжелый образ с компилятором, приложение соберется, но мы потащим всю эту инфраструктуру на production-сервера.

Нам нужен способ разорвать эту связь. Нам нужен механизм, который позволит использовать один (тяжелый) образ для компиляции кода, а результат перекладывать в другой (чистый и легкий) образ, выбрасывая все промежуточные инструменты.

Именно эту задачу решает концепция Multi-stage сборок, к архитектуре которых мы переходим в следующей главе.

Multi-stage Build: Анатомия многоэтапной сборки

Multi-stage Build: Анатомия многоэтапной сборки

Мы выяснили, что тащить компиляторы и исходный код в production — это катастрофа для безопасности и пустая трата дискового пространства. Возникает дилемма: если мы компилируем код локально на ноутбуке, мы теряем главное преимущество Docker — независимую и воспроизводимую среду. А если компилируем внутри контейнера, то получаем раздутый образ с широкой поверхностью атаки.

Долгое время инженеры решали это «костылями»: писали один Dockerfile для сборки, запускали его, скриптом вытаскивали бинарный файл из контейнера на хост-машину, а затем запускали второй Dockerfile, который упаковывал этот чистый бинарник в минималистичный образ.

Механизм Multi-stage build (многоэтапная сборка) позволил делать всё это внутри одного элегантного файла.

Разрушение правила «один Dockerfile — один FROM»

До этого момента мы воспринимали инструкцию FROM как абсолютное начало: она стоит первой строкой и задает фундамент. В многоэтапной сборке инструкций FROM может быть несколько.

Каждый новый FROM начинает новый этап (stage) сборки с абсолютно чистого листа.

FROM ubuntu:22.04
RUN echo "Я первый этап" > /file.txt

FROM alpine:3.18
RUN echo "Я второй этап"

Главное архитектурное правило Multi-stage: финальным образом становится только то, что описано в последнем этапе.

Когда Docker доходит до второго FROM, он оставляет первый этап позади. Все файлы, слои и утилиты из ubuntu:22.04 превращаются во временный мусор, который не попадет в итоговый образ. В примере выше финальный образ будет базироваться на alpine:3.18 и весить около 7 мегабайт, а файл /file.txt исчезнет навсегда.

Именование этапов: псевдоним AS

Чтобы промежуточные этапы имели смысл, нам нужно уметь к ним обращаться. Для этого к инструкции FROM добавляется ключевое слово AS, которое присваивает этапу понятное имя.

FROM maven:3.9-eclipse-temurin-17 AS builder

Теперь среда с тяжелым компилятором Java (Maven) имеет имя builder. Мы можем устанавливать зависимости, копировать исходный код и запускать компиляцию внутри этого этапа, не беспокоясь о размере слоев.

Мост между мирами: COPY --from

Если каждый новый этап начинается с чистого листа, как нам перенести скомпилированное приложение из этапа builder в финальный образ? Для этого инструкция COPY получает специальный флаг --from.

Обычный COPY берет файлы из контекста сборки (с вашего жесткого диска). Конструкция COPY --from=<имя_этапа> меняет источник: она берет файлы из файловой системы указанного промежуточного контейнера.

Практический синтез: Собираем Java-приложение

Соберем эти концепции воедино на примере реального Java-приложения. Нам нужен тяжелый образ Maven (около 400 МБ) для сборки исходников в архив .jar, но для работы самого приложения в production достаточно легковесной среды выполнения JRE (около 80 МБ).

# --- ЭТАП 1: Сборка (Builder) ---
FROM maven:3.9-eclipse-temurin-17 AS builder

# Устанавливаем рабочую директорию
WORKDIR /app

# Копируем файл зависимостей и исходный код
COPY pom.xml .
COPY src ./src

# Компилируем проект (создается файл /app/target/app.jar)
RUN mvn clean package -DskipTests

# --- ЭТАП 2: Production (Runtime) ---
FROM eclipse-temurin:17-jre-alpine

WORKDIR /app

# Копируем ТОЛЬКО готовый артефакт из этапа builder
COPY --from=builder /app/target/app.jar ./app.jar

# Запускаем приложение
CMD ["java", "-jar", "app.jar"]

Посмотрим на этот процесс глазами движка Docker:

  1. Скачивается тяжелый образ maven.
  2. В нем создаются слои с исходным кодом и запускается компиляция. Генерируется множество промежуточных файлов.
  3. Встретив второй FROM, Docker скачивает крошечный образ jre-alpine.
  4. Инструкция COPY --from=builder прорубает «окно» в первый этап, берет оттуда только один файл app.jar и кладет его в новый образ.
  5. Этап builder выбрасывается.

В результате мы получаем образ размером ~85 МБ, который содержит только минимальный Linux, среду выполнения Java и наш скомпилированный код. В нем нет ни исходников, ни системы сборки Maven, ни скачанных библиотек для тестирования. Поверхность атаки сведена к минимуму, а безопасность и скорость деплоя максимизированы.

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

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

Представьте ситуацию: вы написали микросервис на Node.js, который использует библиотеку для обработки изображений. Вы упаковали его в Docker, запустили docker build и получили образ весом 1.4 ГБ. Внутри — исходный код, компиляторы C++, утилиты сборки и полный дистрибутив Debian. Хуже того, приложение работает от имени суперпользователя (root). Если злоумышленник найдет уязвимость в коде, он получит полный контроль над контейнером с богатым арсеналом инструментов внутри.

В этой главе мы сведем воедино все концепции, изученные ранее: управление кэшем, глобальные переменные, отсечение лишнего через Multi-stage. И добавим последний, критически важный штрих — изоляцию на уровне пользователя.

Шаг 1: Исходный «грязный» Dockerfile

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

FROM node:18

WORKDIR /app
COPY . .

# Устанавливаем системные зависимости для сборки нативных модулей (node-gyp)
RUN apt-get update && apt-get install -y python3 make g++

# Устанавливаем зависимости Node.js и собираем проект
RUN npm install
RUN npm run build

CMD ["npm", "start"]

Что здесь не так?

  1. Кэш сломан: COPY . . стоит в самом начале. Любое изменение в файле README.md инвалидирует кэш для тяжелых команд apt-get install и npm install.
  2. Мертвый груз: Компиляторы g++ и python3 остаются в финальном образе навсегда.
  3. Безопасность: Процесс выполняется от имени пользователя root.

Начнем методично превращать этот черновик в production-ready решение.

Шаг 2: Оптимизация кэша и параметризация

Сначала применим знания о слоях и глобальных переменных. Зафиксируем парсер, вынесем версию базового образа в ARG и перестроим порядок инструкций от редко изменяемых к часто изменяемым.

# syntax=docker/dockerfile:1
ARG NODE_VERSION=18.17.0

FROM node:${NODE_VERSION}
WORKDIR /app

# 1. Сначала системные зависимости (меняются крайне редко)
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*

# 2. Затем файлы конфигурации пакетов (меняются при добавлении новых библиотек)
COPY package.json package-lock.json ./
RUN npm install

# 3. И только потом исходный код (меняется постоянно)
COPY . .
RUN npm run build

CMD ["npm", "start"]

Теперь сборка происходит молниеносно при изменении кода: Docker использует кэш для установки пакетов (Cache hit) и пересобирает только последние слои. Но проблема размера и безопасности (наличие компиляторов) все еще с нами.

Шаг 3: Отсечение лишнего через Multi-stage

Применим архитектуру многоэтапной сборки. Нам нужен тяжелый этап builder для компиляции и минималистичный этап runtime для запуска. В качестве базового образа для рантайма выберем alpine — он весит всего около 5 МБ.

# syntax=docker/dockerfile:1
ARG NODE_VERSION=18.17.0

# --- ЭТАП 1: Сборка (Builder) ---
FROM node:${NODE_VERSION} AS builder
WORKDIR /app

RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*

COPY package.json package-lock.json ./
RUN npm install

COPY . .
RUN npm run build
# В результате в папке /app/dist лежит готовый скомпилированный код,
# а в /app/node_modules — установленные зависимости.

# --- ЭТАП 2: Выполнение (Runtime) ---
FROM node:${NODE_VERSION}-alpine
WORKDIR /app

# Забираем только необходимое из этапа builder, оставляя компиляторы позади
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./

CMD ["npm", "start"]

Размер образа только что упал с 1.4 ГБ до ~150 МБ. Поверхность атаки радикально сократилась: внутри больше нет ни apt, ни g++, ни python3.

Шаг 4: Последний рубеж защиты — инструкция USER

Даже в урезанном образе alpine наш процесс по умолчанию запускается с правами root. Если злоумышленник найдет способ выполнить произвольную команду через уязвимость в нашем приложении (RCE), он сделает это с правами суперпользователя внутри контейнера.

Чтобы этого избежать, Docker предоставляет инструкцию USER.

USER — инструкция, которая переключает текущего пользователя (и опционально группу) для всех последующих инструкций RUN, CMD и ENTRYPOINT в Dockerfile.

Официальные образы (включая node) часто уже содержат встроенного непривилегированного пользователя. В образе node он так и называется — node.

Добавим переключение пользователя в наш финальный этап:

# --- ЭТАП 2: Выполнение (Runtime) ---
FROM node:${NODE_VERSION}-alpine
WORKDIR /app

# Копируем артефакты, сразу меняя их владельца на пользователя node
# Иначе файлы будут скопированы от имени root, и приложение не сможет их читать/писать
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node package.json ./

# Переключаемся на непривилегированного пользователя
USER node

# Теперь CMD выполнится от имени пользователя node
CMD ["npm", "start"]

Итог: Анатомия профессионального образа

Мы прошли путь от монолитного, медленного и небезопасного скрипта до эталонного Dockerfile.

Что мы получили в итоге:

  1. Скорость: Благодаря правильному порядку слоев (кэширование package.json до COPY . .), повторные сборки занимают секунды.
  2. Легковесность: Multi-stage сборка отсекла исходники и сборочный инструментарий. Финальный образ содержит только среду выполнения (Alpine) и готовые артефакты.
  3. Безопасность: Отсутствие компиляторов сужает поверхность атаки, а инструкция USER гарантирует, что даже при взломе приложения злоумышленник окажется в песочнице без прав суперпользователя.

На этом этапе вы умеете создавать идеальные контейнеры для вычислений (stateless). Однако реальные приложения часто должны сохранять файлы, загруженные пользователями, или работать с базами данных. Как управлять постоянными данными так, чтобы они не исчезали при пересоздании контейнера, мы разберем в следующем модуле.