Пошук уроків, статей та іншого контенту
Розділите конфігурації розробки та production для швидкого локального циклу й мінімальних безпечних образів.
Розробка та production мають різні вимоги до контейнера:
| Розробка | Production | |---|---| | Швидке оновлення коду без перебудови образу | Незмінний образ із відомим вмістом | | Dev-залежності та інструменти налагодження | Тільки runtime-залежності | | Монтування локального коду | Код вбудований у образ | | Зручність діагностики | Мінімальний розмір і поверхня атаки | | Автоматичне перезавантаження | Стабільний запуск без watcher-ів |
Якщо використовувати один і той самий образ для обох сценаріїв, зазвичай виникають проблеми:
production-образ містить компілятори, dev-залежності та вихідний код;
локальні зміни вимагають повного docker build;
bind mount може випадково перекрити файли всередині production-контейнера;
production запускається з неправильним режимом або командою;
різниця між локальним і production-середовищем стає неявною.
Розділення конфігурацій не означає обов’язково створення двох повністю незалежних Dockerfile. Зазвичай достатньо:
одного Dockerfile із кількома build stages;
базового Compose-файлу;
окремих override-файлів для development і production.
Розглянемо Node.js-застосунок із такою структурою:
.
├── Dockerfile
├── .dockerignore
├── compose.yml
├── compose.dev.yml
├── compose.prod.yml
├── package.json
├── package-lock.json
└── server.jsПриклад package.json:
{
"name": "docker-environments-example",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "node --watch server.js",
"build": "mkdir -p dist && cp server.js dist/server.js",
"start": "node dist/server.js"
}
}Приклад простого server.js:
const http = require("node:http");
const port = Number(process.env.PORT || 3000);
const server = http.createServer((request, response) => {
response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
response.end(`Environment: ${process.env.NODE_ENV || "undefined"}\n`);
});
server.listen(port, "0.0.0.0", () => {
console.log(`Server is listening on port ${port}`);
});FROM node:22-bookworm-slim AS base
WORKDIR /app
FROM base AS dependencies
COPY package.json package-lock.json ./
RUN npm ci
FROM dependencies AS development
ENV NODE_ENV=development
COPY . .
CMD ["npm", "run", "dev"]
FROM dependencies AS build
COPY . .
RUN npm run build
FROM base AS production
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build --chown=node:node /app/dist ./dist
USER node
CMD ["npm", "start"]У цьому Dockerfile є кілька важливих stages:
base містить спільні налаштування;
dependencies встановлює всі залежності, включно з dev-залежностями;
development призначений для локальної роботи;
build виконує складання застосунку;
production містить лише production-залежності та результат складання.
Команди docker build або Compose використовують останній stage за замовчуванням. Щоб вибрати інший stage, застосовують параметр target.
node_modulesУ development і build потрібні dev-залежності. У production вони не потрібні, тому stage production окремо виконує:
RUN npm ci --omit=devЦе збільшує тривалість складання production-образу, але дає важливі переваги:
у фінальному образі немає інструментів складання;
dev-залежності не потрапляють у production;
production-образ має чітко визначений набір пакетів;
помилки конфігурації dev-залежностей не маскуються випадковим копіюванням node_modules.
.dockerignoreКонтекст складання має бути мінімальним:
node_modules
.git
.gitignore
.env
.env.*
dist
coverage
npm-debug.log
Dockerfile*
compose*.ymlНе варто передавати в Docker-контекст:
локальний node_modules;
секрети та .env-файли;
історію Git;
результати попередніх локальних складань;
журнали та coverage-файли.
Файл package-lock.json не потрібно ігнорувати. Він має потрапляти в контекст і бути закоміченим до репозиторію, щоб npm ci встановлював відтворюваний набір залежностей.
compose.yml може містити спільні параметри, які не залежать від середовища:
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
PORT: "3000"Цей файл описує сам сервіс, але не визначає, чи потрібно монтувати вихідний код або який build stage використовувати.
Створимо compose.dev.yml:
services:
app:
build:
target: development
environment:
NODE_ENV: development
volumes:
- .:/app
- /app/node_modules
command: npm run devЗапуск:
docker compose -f compose.yml -f compose.dev.yml up --buildПерший volume:
- .:/appмонтує локальний каталог у контейнер. Завдяки цьому зміни у файлах одразу доступні процесу всередині контейнера.
Другий volume:
- /app/node_modulesстворює окремий anonymous volume для node_modules. Він потрібен тому, що bind mount .:/app може перекрити каталог /app/node_modules, який був встановлений під час складання образу.
Без цього застосунок може завершитися з помилкою на кшталт:
Cannot find module ...У development-контейнері watcher запускається командою:
"dev": "node --watch server.js"У production watcher не використовується. Процес запускається безпосередньо з результату складання.
Створимо compose.prod.yml:
services:
app:
build:
target: production
environment:
NODE_ENV: production
restart: unless-stoppedЗапуск production-конфігурації:
docker compose -f compose.yml -f compose.prod.yml up --build -dУ production не повинно бути:
bind mount локального каталогу;
node_modules з хост-системи;
команди npm run dev;
watcher-а;
dev-залежностей;
залежності від файлів, які не були скопійовані в образ.
Перевірити об’єднану конфігурацію можна командою:
docker compose -f compose.yml -f compose.prod.yml configЦе корисно перед запуском: команда показує фінальну конфігурацію після злиття всіх файлів.
У команді:
docker compose -f compose.yml -f compose.dev.yml upдругий файл застосовується поверх першого.
Спільний файл задає базову конфігурацію:
services:
app:
build:
context: .
dockerfile: DockerfileDevelopment-файл змінює або доповнює її:
services:
app:
build:
target: development
volumes:
- .:/appТому важливо перевіряти результат через docker compose config, а не припускати, що фінальна конфігурація виглядає саме так, як окремі YAML-файли.
Вибір build target має принципове значення:
docker build --target development -t example-app:dev .
docker build --target production -t example-app:prod .Ці образи відрізняються не тільки командою запуску:
development містить вихідний код і dev-залежності;
production містить результат складання та production-залежності;
development розрахований на bind mount;
production є самодостатнім і не потребує файлів із хост-системи;
production запускається від непривілейованого користувача node.
Зміна лише NODE_ENV не перетворює development-образ на production-образ. Якщо в образі вже присутні dev-залежності або компілятори, змінна середовища їх не видаляє.
Фінальний stage використовує:
USER nodeЦе зменшує наслідки можливої вразливості застосунку. Процес не матиме root-привілеїв усередині контейнера.
Файли, які копіюються в образ, мають бути доступними цьому користувачеві:
COPY --from=build --chown=node:node /app/dist ./distНе слід передавати секрети через ARG або записувати їх через ENV під час складання:
ARG DATABASE_PASSWORD
ENV DATABASE_PASSWORD=$DATABASE_PASSWORDЗначення, передані під час складання, можуть залишитися в історії шарів або метаданих образу.
Конфігурацію, яка потрібна вже під час запуску, передають через runtime-механізми Compose або платформи розгортання. Секрети не повинні потрапляти до образу.
Фінальний stage має копіювати лише необхідні артефакти:
COPY --from=build --chown=node:node /app/dist ./distНе потрібно копіювати в production:
вихідні файли, якщо вони не потрібні runtime;
конфігурації development;
тести;
документацію;
кеші пакетного менеджера;
інструменти складання.
Порядок інструкцій у Dockerfile впливає на швидкість повторного складання:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Якщо змінився лише server.js, Docker може використати кеш для шару npm ci. Якби спочатку копіювався весь проєкт, кожна зміна вихідного коду змушувала б повторно встановлювати залежності.
Для development швидкість додатково забезпечується bind mount: код не потрібно копіювати в новий образ після кожної зміни.
Корисно перевірити обидві конфігурації окремо:
docker compose -f compose.yml -f compose.dev.yml config
docker compose -f compose.yml -f compose.prod.yml configПотім можна запустити production-контейнер і перевірити:
docker compose -f compose.yml -f compose.prod.yml up --buildВ іншому терміналі:
curl http://localhost:3000Очікуваний результат:
Environment: productionДля development:
docker compose -f compose.yml -f compose.dev.yml up --buildОчікуваний результат:
Environment: developmentЯкщо змінити server.js під час development, процес node --watch автоматично перезапустить застосунок. У production зміна локального файлу не повинна впливати на вже створений контейнер.
Якщо запустити target production із bind mount, локальний код може замінити вміст /app, але каталог dist ще не буде оновлений. Контейнер працюватиме зі старим результатом складання або завершиться з помилкою.
Для локальної роботи використовуйте target development.
node_modules із хостаЛокальні залежності можуть бути встановлені для іншої операційної системи або архітектури. Наприклад, native-модуль, встановлений на macOS, не обов’язково працюватиме в Linux-контейнері.
Залежності для контейнера мають встановлюватися всередині контейнера або під час складання образу.
.env у build contextДодавання .env у контекст складання ризикує включити секрети до шарів образу. Переконайтеся, що .env і подібні файли вказані в .dockerignore.
NODE_ENVNODE_ENV=production не видаляє dev-залежності та не змінює Dockerfile. Це лише змінна середовища, яку читає застосунок або бібліотеки.
Мінімальний production-образ потрібно створити окремим build stage.
Конфігурація на кшталт:
volumes:
- .:/appпідходить для development, але суперечить ідеї незмінного production-образу. Production має запускатися з артефактів, які були перевірені та зібрані заздалегідь.
package-lock.jsonnpm ci вимагає lock-файл. Якщо його немає або він не відповідає package.json, складання завершиться помилкою.
Lock-файл потрібно створити локально командою:
npm installі зберігати разом із кодом.
Development і production мають різні вимоги, тому їх потрібно конфігурувати окремо.
Multi-stage Dockerfile дає змогу створювати development- і production-образи з одного джерела.
Development-конфігурація використовує bind mount, dev-залежності та watcher.
Production-конфігурація не монтує локальний код і містить лише необхідні runtime-артефакти.
npm ci --omit=dev зменшує набір залежностей у production.
USER node не дозволяє запускати застосунок від root.
.dockerignore зменшує контекст складання та допомагає не передавати секрети.
docker compose config дає змогу перевірити фінальну конфігурацію після об’єднання YAML-файлів.
NODE_ENV сам по собі не створює production-образ — це потрібно забезпечити структурою Dockerfile.