Docker - что это и зачем нужно
Представьте ситуацию: вы написали приложение, всё работает на вашем компьютере. Отправляете коллеге - не работает. Деплоите на сервер - не работает. "У меня всё работает!" - фраза, которая стала мемом в мире разработки.
Docker решает именно эту проблему. Он позволяет "упаковать" ваше приложение вместе со всем окружением (Node.js, библиотеки, настройки) в контейнер - изолированную коробку, которая работает одинаково везде: на вашем ноутбуке, на компьютере коллеги, на сервере, в облаке - где угодно.
Звучит как магия? Давайте разберёмся, как это работает. Обещаю объяснить так, чтобы было понятно даже если вы Docker видите впервые.
Основные понятия (без них дальше будет непонятно)
Прежде чем писать код, давайте разберём три ключевых термина:
- Dockerfile - это файл-инструкция. Как рецепт для приготовления блюда. В нём написано: "возьми Node.js, скопируй мой код, установи зависимости, запусти сервер".
- Образ (Image) - это результат выполнения Dockerfile. Готовое "блюдо по рецепту". Его можно хранить, копировать, отправлять коллегам.
- Контейнер (Container) - это запущенный образ. Как если бы вы достали готовое блюдо из холодильника и поставили на стол. Контейнер работает - образ просто хранится.
Аналогия для совсем простого понимания: Dockerfile - это рецепт пиццы. Образ - замороженная пицца в коробке. Контейнер - горячая пицца на тарелке, которую вы едите прямо сейчас.
Шаг 1: Первый Dockerfile
Создайте файл с названием Dockerfile (без расширения) в корне вашего проекта:
# Строка 1: С чего начинаем
# FROM = "возьми за основу"
# node:20-alpine = Node.js версии 20 на базе Alpine Linux
# Alpine - это суперлёгкий Linux (5 МБ вместо 900 МБ у обычного)
FROM node:20-alpine
# Строка 2: Рабочая папка внутри контейнера
# Все следующие команды будут выполняться в /app
# Это как "cd /app", но для Docker
WORKDIR /app
# Строка 3-4: Копируем файлы зависимостей
# COPY откуда куда
# Копируем package.json и package-lock.json из нашего проекта в контейнер
# ЗАЧЕМ ОТДЕЛЬНО? Docker кеширует каждый шаг.
# Если package.json не менялся - npm install не будет повторяться.
# Это экономит МИНУТЫ при каждой пересборке!
COPY package.json package-lock.json ./
# Строка 5: Устанавливаем зависимости
# npm ci - это как npm install, но строже:
# устанавливает ТОЧНО те версии, что в lock-файле
RUN npm ci
# Строка 6: Копируем весь остальной код
# Это делаем ПОСЛЕ npm ci, чтобы изменения в коде
# не сбрасывали кеш установки зависимостей
COPY . .
# Строка 7: Говорим Docker, что приложение слушает порт 3000
# Это для документации - фактически порт откроем при запуске
EXPOSE 3000
# Строка 8: Команда запуска
# Когда контейнер стартует, выполнится эта команда
CMD ["node", "server.js"]
Собираем и запускаем
# Собираем образ из Dockerfile
# -t my-app = даём образу имя "my-app"
# . = ищи Dockerfile в текущей папке
docker build -t my-app .
# Запускаем контейнер из образа
# -p 3000:3000 = перенаправляй порт 3000 снаружи на порт 3000 внутри
# Без этого вы не достучитесь до приложения из браузера
docker run -p 3000:3000 my-app
# Теперь откройте http://localhost:3000 - ваше приложение работает!
# Запуск в фоновом режиме (чтобы терминал не был занят)
# -d = detached (фоновый режим)
# --name my-app = даём контейнеру имя для удобства
docker run -d -p 3000:3000 --name my-app my-app
# Посмотреть логи работающего контейнера
# -f = follow (следить в реальном времени, как tail -f)
docker logs -f my-app
# Остановить контейнер
docker stop my-app
# Удалить контейнер
docker rm my-app
Шаг 2: .dockerignore (обязательно!)
Когда Docker выполняет COPY . ., он копирует ВСЁ из папки проекта. Включая node_modules (которые мы и так устанавливаем внутри), папку .git, логи и прочий мусор. Это как переезжать в новую квартиру и тащить с собой содержимое мусорного ведра.
Создайте файл .dockerignore в корне проекта:
# .dockerignore - указываем что НЕ НУЖНО копировать в контейнер
# node_modules - установим заново внутри (чтобы были правильные бинарники)
node_modules
# Git - история коммитов в контейнере не нужна
.git
.gitignore
# Секреты - НИКОГДА не должны попадать в образ!
.env
.env.local
.env.production
# Файлы для разработки
dist
coverage
*.md
.vscode
.idea
# Сам Dockerfile и docker-compose (рекурсия нам ни к чему)
Dockerfile
docker-compose*.yml
.dockerignore
Без .dockerignore ваш образ может весить 500 МБ - 2 ГБ. С ним - 80-120 МБ. Разница колоссальная, особенно когда вы деплоите десятки раз в день.
Шаг 3: Multi-stage Build (для production)
Это самая мощная техника Docker для production. Идея: мы используем два контейнера. В первом - собираем проект (там есть TypeScript, dev-зависимости, всё для сборки). Во второй - копируем только результат сборки (уже скомпилированный JavaScript). Dev-зависимости остаются в первом контейнере и не попадают в финальный образ.
Аналогия: вы построили дом. Для строительства нужны были краны, бетономешалки, строительные леса. Но когда дом готов - всю технику увезли. В доме остался только дом. Multi-stage build работает так же.
# ============== СТАДИЯ 1: СБОРКА ==============
# Называем эту стадию "builder" (имя может быть любым)
FROM node:20-alpine AS builder
WORKDIR /app
# Устанавливаем ВСЕ зависимости (включая devDependencies)
# Они нужны для компиляции TypeScript
COPY package.json package-lock.json ./
RUN npm ci
# Копируем исходный код и конфигурацию TypeScript
COPY tsconfig.json ./
COPY src/ ./src/
# Компилируем TypeScript в JavaScript
# Результат окажется в папке dist/
RUN npm run build
# В этом контейнере сейчас:
# - Node.js + npm
# - ВСЕ зависимости (включая typescript, eslint, jest и т.д.)
# - Исходный код (src/)
# - Скомпилированный код (dist/)
# Весит всё это ~400-500 МБ
# ============== СТАДИЯ 2: PRODUCTION ==============
# Начинаем с ЧИСТОГО образа (всё из стадии 1 забываем)
FROM node:20-alpine AS production
WORKDIR /app
# Устанавливаем только production-зависимости
# (без typescript, jest, eslint и прочих dev-инструментов)
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
# Копируем ТОЛЬКО скомпилированный код из стадии builder
# --from=builder = "возьми из стадии с именем builder"
COPY --from=builder /app/dist ./dist
# Запускаем от непривилегированного пользователя
# Это важно для безопасности!
# Если кто-то взломает приложение, у него не будет прав root
USER node
# Переменные окружения
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
# Health check - Docker будет проверять, живо ли приложение
# Каждые 30 секунд делает запрос на /health
# Если 3 раза подряд не ответило - контейнер считается "нездоровым"
HEALTHCHECK --interval=30s --timeout=3s --retries=3
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
# Итоговый образ содержит ТОЛЬКО:
# - Node.js
# - Production-зависимости
# - Скомпилированный JavaScript
# Весит ~80-120 МБ вместо ~500 МБ. Красота!
Шаг 4: Docker Compose (для локальной разработки)
В реальном проекте вам нужно не только Node.js приложение, но и база данных, может быть Redis для кеша, и ещё пара сервисов. Запускать каждый вручную - боль. Docker Compose позволяет описать все сервисы в одном файле и запустить одной командой.
# docker-compose.yml
# Описание всех сервисов нашего проекта
version: '3.8'
services:
# ======= Наше Node.js приложение =======
app:
build:
context: . # Где искать Dockerfile
target: builder # Используем стадию builder (с dev-зависимостями)
ports:
- "3000:3000" # Порт приложения
- "9229:9229" # Порт для отладки (debug)
volumes:
- .:/app # Монтируем папку проекта внутрь контейнера
# Изменения в коде сразу видны без пересборки!
- /app/node_modules # НО node_modules берём из контейнера, не с хоста
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:secret@db:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
db: # Дождись запуска базы данных
condition: service_healthy # И убедись что она готова
command: npm run dev # Запускаем в режиме разработки (с hot-reload)
# ======= База данных PostgreSQL =======
db:
image: postgres:16-alpine # Готовый образ PostgreSQL
environment:
POSTGRES_DB: myapp # Имя базы данных
POSTGRES_USER: postgres # Пользователь
POSTGRES_PASSWORD: secret # Пароль (в production используйте .env!)
ports:
- "5432:5432" # Порт для подключения извне (pgAdmin и т.д.)
volumes:
- pgdata:/var/lib/postgresql/data # Данные сохраняются между перезапусками
healthcheck: # Проверка готовности
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
# ======= Redis (кеш и сессии) =======
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
# Именованные volumes - данные не пропадут при перезапуске
volumes:
pgdata:
redisdata:
Основные команды Docker Compose
# Запустить все сервисы (в фоне)
# -d = detached (фоновый режим)
docker compose up -d
# Docker скачает образы, создаст контейнеры, настроит сеть между ними
# Всё одной командой!
# Посмотреть логи конкретного сервиса
docker compose logs -f app
# Посмотреть статус всех сервисов
docker compose ps
# Перезапустить один сервис
docker compose restart app
# Остановить все сервисы
docker compose down
# Остановить и удалить ВСЕ данные (включая базу данных!)
# Осторожно! Это удалит все данные из PostgreSQL и Redis
docker compose down -v
Шаг 5: Работа с секретами
Пароли, API-ключи и другие секреты никогда не должны попадать в Dockerfile или в образ. Вот как это делать правильно:
# Создайте файл .env (добавьте его в .gitignore и .dockerignore!)
# .env
DATABASE_URL=postgres://user:password@db:5432/myapp
JWT_SECRET=мой-суперсекретный-ключ
STRIPE_API_KEY=sk_live_xxx
SMTP_PASSWORD=пароль-от-почты
# В docker-compose.yml:
services:
app:
env_file:
- .env # Docker прочитает файл и передаст переменные в контейнер
// В коде Node.js читаем секреты из переменных окружения:
const dbUrl = process.env.DATABASE_URL; // postgres://user:...
const jwtSecret = process.env.JWT_SECRET; // мой-суперсекретный-ключ
// НИКОГДА не пишите так:
// const password = 'мой-пароль'; // Это попадёт в Git и в Docker-образ!
Шаг 6: Оптимизация
Правильный порядок команд (кеширование слоёв)
Docker кеширует каждую команду. Если файлы, которые копирует COPY, не изменились - следующие команды тоже берутся из кеша. Поэтому порядок важен:
# ПРАВИЛЬНО:
# 1. Сначала копируем то, что меняется РЕДКО (package.json)
COPY package.json package-lock.json ./
# 2. Устанавливаем зависимости (берётся из кеша если package.json не менялся)
RUN npm ci
# 3. В конце копируем то, что меняется ЧАСТО (исходный код)
COPY . .
# Результат: при изменении кода npm ci НЕ выполняется заново
# Сборка занимает 5 секунд вместо 2 минут!
# НЕПРАВИЛЬНО:
COPY . . # Любое изменение файла сбрасывает кеш ВСЕГО
RUN npm ci # Выполняется КАЖДЫЙ раз заново (2 минуты...)
# Результат: изменили одну строчку кода = 2 минуты на пересборку
Полезные команды для отладки
# Зайти внутрь работающего контейнера (как SSH на сервер)
docker exec -it my-app sh
# Теперь вы "внутри" контейнера, можете осмотреться
# Посмотреть что внутри образа (без запуска приложения)
docker run --rm -it my-app sh
# Посмотреть последние 100 строк логов
docker logs my-app --tail 100
# Следить за логами в реальном времени
docker logs -f my-app
# Сколько ресурсов потребляет контейнер
docker stats my-app
# Посмотреть все образы и их размеры
docker images
# Удалить неиспользуемые образы и контейнеры (чистка)
docker system prune -a
# Осторожно! Удалит ВСЕ неиспользуемые образы
Чек-лист для production
- Создан
.dockerignore(node_modules, .git, .env, dist) - Используется multi-stage build (сборка отдельно, production отдельно)
- Базовый образ - Alpine (маленький размер)
- Используется
npm ciвместоnpm install - Приложение запускается от пользователя
node, не отroot - Добавлен
HEALTHCHECKдля мониторинга - Секреты передаются через .env файлы или Docker Secrets, а не захардкожены
- Версии образов зафиксированы (
node:20-alpine, неnode:latest) - Слои в правильном порядке (package.json перед COPY . .)
- Один процесс на контейнер (не запускайте nginx и node в одном контейнере)
Итого
Docker для Node.js - это проще, чем кажется. Базовый Dockerfile - это буквально 8 строк. Multi-stage build добавляет ещё 10 строк, но уменьшает размер образа в 5 раз. Docker Compose позволяет поднять всю инфраструктуру одной командой.
Начните с простого Dockerfile, убедитесь что всё работает, и постепенно добавляйте оптимизации. Не нужно сразу делать идеально - Docker хорош тем, что можно улучшать постепенно.
И напоследок: если ваш Docker-образ весит больше гигабайта - проверьте .dockerignore. В 90% случаев туда забыли добавить node_modules. Остальные 10% - это когда кто-то случайно закоммитил дамп базы данных в репозиторий. Но это уже другая история и другая статья.