Пошук уроків, статей та іншого контенту
Зменште розмір і поверхню атаки образу за допомогою multi-stage build та відокремлення етапів складання від запуску.
Звичайний Docker-образ Node.js часто містить усе, що потрібно для складання застосунку:
компілятор TypeScript;
dev-залежності;
початковий код;
конфігураційні файли;
кеш пакетного менеджера.
Для запуску застосунку більшість цих файлів не потрібна. У production достатньо мати:
production-залежності;
скомпільований код;
мінімальну конфігурацію для запуску.
Multi-stage build дає змогу описати складання і запуск в одному Dockerfile, але виконати їх у різних етапах. Docker використовує результати одного етапу в іншому, а зайві файли не потрапляють до фінального образу.
Це допомагає:
зменшити розмір образу;
скоротити час передавання образу;
зменшити кількість компонентів із потенційними вразливостями;
не включати dev-залежності та інструменти складання у production.
Нехай застосунок написаний на TypeScript і під час складання компілюється у JavaScript.
Структура проєкту:
project/
├── src/
│ └── server.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfilepackage.json{
"name": "multi-stage-example",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.0.0"
}
}У цьому прикладі TypeScript і типи Node.js є dev-залежностями. Вони потрібні лише під час складання.
tsconfig.json{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}src/server.tsimport { createServer } from "node:http";
const server = createServer((_request, response) => {
response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Застосунок працює\n");
});
server.listen(3000, "0.0.0.0", () => {
console.log("Сервер слухає порт 3000");
});Перед складанням образу потрібно встановити залежності та створити package-lock.json:
npm installФайл блокування версій потрібно зберігати в системі контролю версій. Команда npm ci використовує саме його для відтворюваного встановлення залежностей.
FROM node:22-bookworm-slim AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-bookworm-slim AS production
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]У цьому файлі описано три етапи:
dependencies — встановлення всіх залежностей;
build — складання TypeScript-коду;
production — створення мінімального образу для запуску.
dependenciesFROM node:22-bookworm-slim AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ciDocker спочатку копіює лише package.json і package-lock.json, а потім встановлює залежності.
Це важливо для кешування шарів. Якщо змінити файл у src, але не змінити залежності, Docker зможе використати вже створений шар із npm ci.
На цьому етапі встановлюються і звичайні, і dev-залежності, оскільки вони потрібні для складання.
buildFROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run buildЕтап build успадковує встановлені залежності з етапу dependencies.
Команда npm run build запускає TypeScript-компілятор. У результаті з’являється каталог dist:
dist/
└── server.jsУ фінальний образ потрібно перенести лише цей результат, а не весь каталог проєкту.
productionFROM node:22-bookworm-slim AS production
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./distЕтап production починається з чистого образу Node.js. Він не успадковує файлову систему етапу build.
Команда:
RUN npm ci --omit=devвстановлює лише production-залежності. У цьому прикладі їх немає, але такий підхід підходить і для застосунків із runtime-залежностями, наприклад вебфреймворками.
Рядок:
COPY --from=build /app/dist ./distкопіює каталог dist з етапу build у фінальний образ.
До фінального образу не потрапляють:
TypeScript;
@types/node;
початкові файли з src;
конфігурація компілятора;
інші файли, які були потрібні лише для складання.
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]USER node запускає процес не від імені root. Це зменшує наслідки можливої помилки або вразливості в застосунку.
EXPOSE документує порт, який використовує застосунок. Він не публікує порт автоматично. Під час запуску потрібно використати параметр -p:
docker build -t multi-stage-example .
docker run --rm -p 3000:3000 multi-stage-exampleПісля цього застосунок буде доступний на порту 3000 локальної машини.
.dockerignoreУ контекст збірки не потрібно передавати файли, які не потрібні Dockerfile:
node_modules
dist
.git
.gitignore
Dockerfile
npm-debug.logОсобливо важливо виключити node_modules. Залежності повинні встановлюватися всередині контейнера командою npm ci, а не копіюватися з операційної системи розробника.
Каталог dist також можна виключити: він створюється на етапі build усередині Docker.
За замовчуванням результатом команди docker build стає останній етап у Dockerfile. У прикладі це:
FROM node:22-bookworm-slim AS productionІнші етапи використовуються лише як проміжні джерела файлів або шарів.
Можна явно вказати етап для перевірки:
docker build --target build -t multi-stage-build .Такий образ міститиме інструменти й залежності для складання, але не є production-образом. Для запуску застосунку потрібно збирати образ без --target або вказувати:
docker build --target production -t multi-stage-example .В одностадійному варіанті Dockerfile міг би виглядати так:
FROM node:22-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]Такий образ працює, але містить:
усі dev-залежності;
TypeScript;
початковий код;
конфігурацію для компіляції;
потенційно непотрібні кеші та файли.
Багатоетапна версія залишає у фінальному образі тільки те, що потрібно для запуску.
Порядок інструкцій у Dockerfile впливає на кешування. Залежності варто встановлювати до копіювання початкового коду:
COPY package*.json ./
RUN npm ci
COPY src ./src
RUN npm run buildЯкщо спочатку виконати:
COPY . .
RUN npm ciто будь-яка зміна в будь-якому файлі контексту може призвести до повторного встановлення всіх залежностей.
Не слід робити так:
COPY --from=build /app /appЦе перенесе у фінальний образ node_modules, початковий код, конфігурацію та інші файли етапу складання.
Краще копіювати лише результат:
COPY --from=build /app/dist ./distpackage-lock.jsonКоманда npm ci очікує наявність файлу package-lock.json або сумісного lock-файлу. Якщо його немає, збірка завершиться помилкою.
Створіть його локально:
npm installі додайте до проєкту.
Якщо у фінальному етапі виконати:
RUN npm ciто будуть встановлені також dev-залежності.
Для production використовуйте:
RUN npm ci --omit=devnode_modules із хостаЛокальні залежності можуть бути встановлені для іншої операційної системи або архітектури. Наприклад, native-модулі, зібрані на macOS, не обов’язково працюватимуть у Linux-контейнері.
Не копіюйте їх у контейнер і додайте node_modules до .dockerignore.
rootЯкщо не вказати користувача, процес у багатьох базових образах запускатиметься від імені root.
Для Node.js-образів, де доступний вбудований користувач node, можна використовувати:
USER nodeВажливо встановлювати цей користувач після виконання операцій, яким потрібні права адміністратора.
Файли, створені на одному етапі, не стають автоматично доступними на іншому. Щоб перенести їх, потрібно явно використати:
COPY --from=build /app/dist ./distПісля складання образу можна переглянути його розмір:
docker images multi-stage-exampleДля перевірки шарів:
docker history multi-stage-exampleФінальний образ має містити production-оточення та скомпільований код, але не інструменти, які залишилися тільки на етапах dependencies і build.
Multi-stage build розділяє складання і запуск застосунку.
Кожен етап починається з власного FROM.
Етап складання може містити компілятори та dev-залежності.
Фінальний етап повинен містити лише runtime-залежності та результат складання.
COPY --from=build дає змогу перенести вибрані файли між етапами.
npm ci --omit=dev не встановлює dev-залежності у production-образі.
.dockerignore не дає зайвим локальним файлам потрапити в контекст збірки.
USER node допомагає запускати застосунок із меншими привілеями.