Как делать11 октября 2026 г.7 мин чтения
Docker и деплойШаг 3 из 8

Docker с нуля: образ, контейнер, Dockerfile

Коротко

Образ Docker — неизменяемый шаблон из слоёв: система, рантайм, зависимости и код приложения. Контейнер — запущенный из образа изолированный процесс со своим слоем для записи. Образ описывают в Dockerfile; для Node.js хороший Dockerfile — многоэтапный, с .dockerignore, непривилегированным пользователем и HEALTHCHECK, а данные хранятся в томах, а не внутри контейнера.

Пошаговый урокУровень: НачальныйDockerПрактика: ~45–90 мин

После материала вы сможете

  • объяснить разницу между образом, контейнером, слоем, реестром и томом
  • написать многоэтапный Dockerfile для Node.js-приложения с .dockerignore, USER node и HEALTHCHECK и упорядочить инструкции под кэш сборки
  • запустить контейнер с портом, переменными окружения и томом и разобраться в нём через docker ps, logs и exec
  • объяснить, почему docker stop может ждать 10 секунд, и исправить это обработкой SIGTERM или --init

Проверено на

Docker Engine 29.6 (Docker Desktop, WSL 2)BuildKit, dockerfile:1Node.js 24.21 (node:24.21.0-alpine, Alpine 3.24)Express 5.3.0TypeScript 7.0.2

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Образ, контейнер, реестр: что делает Docker
  2. 02Приложение, которое будем упаковывать
  3. 03Первый Dockerfile и что с ним не так
  4. 04Dockerfile по правилам: стадии, .dockerignore, USER, HEALTHCHECK
  5. 05Слои и кэш сборки
  6. 06Запуск: run, ps, logs, exec, stop
  7. 07Порты, переменные окружения и тома
  8. 08Частые ошибки и упражнения

Образ, контейнер, реестр: что делает Docker

Проблема «у меня работает» возникает из-за разницы окружений: другая версия Node.js, другая ОС, забытая системная библиотека. Docker упаковывает приложение вместе со всем, что ему нужно, и запускает одинаково на ноутбуке, в CI и на сервере. Документация Docker определяет контейнер как изолированный процесс: в отличие от виртуальной машины у него нет своего ядра — все контейнеры на машине используют ядро хоста, поэтому запускаются за доли секунды.

Образ (image)

Что это
Неизменяемый набор слоёв: файлы, библиотеки, рантайм, код и команда запуска
Аналогия
Установочный диск

Контейнер (container)

Что это
Запущенный из образа процесс плюс тонкий слой для записи поверх слоёв образа
Аналогия
Установленная и запущенная программа

Dockerfile

Что это
Текстовый рецепт сборки образа, по инструкции на слой
Аналогия
Исходный код диска

Реестр (registry)

Что это
Хранилище образов; по умолчанию — Docker Hub
Аналогия
Магазин дисков

Том (volume)

Что это
Хранилище данных, которое живёт дольше контейнера
Аналогия
Внешний диск

Слой — набор изменений файловой системы: добавленные, изменённые и удалённые файлы. Созданный слой не меняется, а одинаковые слои разные образы используют совместно: образ node:24.21.0-alpine скачивается один раз, сколько бы приложений на нём ни было. Попробуйте запустить Node.js, не устанавливая его:

terminal
$ docker run --rm node:24.21.0-alpine node -e "console.log(process.version, process.platform, process.arch)"
v24.21.0 linux x64
$ docker image ls node:24.21.0-alpine
IMAGE                 ID             DISK USAGE   CONTENT SIZE   EXTRA
node:24.21.0-alpine   ebfe2f904627        242MB           62MB        
Docker Engine 29.6 (Docker Desktop, Windows 11 + WSL 2), Git Bash, реальный вывод. --rm удаляет контейнер после выхода.

Урок предполагает, что Docker уже установлен (Docker Desktop на Windows и macOS, Docker Engine на Linux), а Node.js-проект вам знаком по уроку «Node.js с нуля». Команды запускались в Git Bash; в PowerShell они те же, кроме time и sleep.

Приложение, которое будем упаковывать

Небольшой API заметок на Express 5 и TypeScript: GET /health для проверки живости, GET и POST /api/notes, заметки сохраняются в JSON-файл в папке DATA_DIR. TypeScript нужен только при сборке — это и есть повод для многоэтапного Dockerfile. Обработчик SIGTERM нужен, чтобы docker stop завершал приложение аккуратно.

package.json
{
  "name": "notes-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^5.3.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^24.19.2",
    "typescript": "^7.0.2"
  }
}
Версии зависимостей — актуальные в npm на 11.10.2026.
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
Компиляция src → dist.
src/server.ts
import express from "express";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

type Note = { id: number; text: string; createdAt: string };

const port = Number(process.env.PORT ?? 3000);
const dataDir = process.env.DATA_DIR ?? "./data";
const file = join(dataDir, "notes.json");

async function loadNotes(): Promise<Note[]> {
  try {
    return JSON.parse(await readFile(file, "utf8")) as Note[];
  } catch {
    return [];
  }
}

const app = express();
app.use(express.json());

app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: Math.round(process.uptime()) });
});

app.get("/api/notes", async (_req, res) => {
  res.json(await loadNotes());
});

app.post("/api/notes", async (req, res) => {
  const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
  if (!text) {
    res.status(400).json({ error: "text is required" });
    return;
  }
  const notes = await loadNotes();
  const note: Note = { id: notes.length + 1, text, createdAt: new Date().toISOString() };
  notes.push(note);
  await mkdir(dataDir, { recursive: true });
  await writeFile(file, JSON.stringify(notes, null, 2));
  res.status(201).json(note);
});

const server = app.listen(port, () => {
  console.log(`notes-api listening on port ${port} (NODE_ENV=${process.env.NODE_ENV ?? "development"})`);
});

// docker stop sends SIGTERM: finish open requests and exit instead of waiting to be killed
process.on("SIGTERM", () => {
  console.log("SIGTERM received, shutting down");
  server.close(() => process.exit(0));
});
Проверено: tsc без ошибок, запуск в Node.js 24.21 и 24.18.
bash
npm install express@5.3.0
npm install -D typescript@7.0.2 @types/express@5.0.6 @types/node@24.19.2
npx tsc && node dist/server.js
Установка зависимостей и локальная проверка без Docker; package-lock.json нужен для npm ci в образе.

Первый Dockerfile и что с ним не так

Начнём с Dockerfile, который пишут в первый раз. Он работает, и на нём хорошо видны типичные ошибки.

Dockerfile.naive
FROM node:24.21.0-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Наивная версия — не для продакшена.
terminal
$ docker build -t notes-api:naive -f Dockerfile.naive .
…
#6 [internal] load build context
#6 transferring context: 36.09MB 3.1s done
#7 [3/5] COPY . .
#8 [4/5] RUN npm install
#8 1.800 added 1 package, removed 1 package, and audited 79 packages in 2s
#9 [5/5] RUN npm run build
#10 naming to docker.io/library/notes-api:naive done
Выдержка из вывода docker build --progress=plain (Docker 29.6, BuildKit).
terminal
$ docker run -d --name notes-naive -p 127.0.0.1:8081:3000 notes-api:naive
a372e6afa938bdfd8a9c6ce1d2e2db88ef4e55460403ea4ae7e78902308e5d6d
$ sleep 2; docker exec notes-naive ps -o pid,user,args
PID   USER     COMMAND
    1 root     npm start
   18 root     {MainThread} node dist/server.js
   25 root     ps -o pid,user,args
$ time docker stop notes-naive
notes-naive

real	0m0.528s
user	0m0.060s
sys	0m0.077s
$ docker rm notes-naive
notes-naive
$ docker image ls notes-api
IMAGE                ID             DISK USAGE   CONTENT SIZE   EXTRA
notes-api:1.0        98bb9f6c383d        247MB         62.3MB        
notes-api:naive      fcc71eb114bb        354MB           93MB        
Сравнение наивного образа с итоговым из следующего раздела, реальный вывод.
  • COPY . . без .dockerignore отправил сборщику 36 МБ — в основном node_modules с Windows-машины; npm пришлось заменить пакет со скомпилированным под Windows компилятором TypeScript. Так же в образ попал бы и .env с секретами
  • Любая правка кода сбрасывает кэш слоя с COPY . ., и npm install выполняется заново при каждой сборке
  • В образе остались TypeScript и типы — инструменты сборки, которые в работе не нужны: 354 МБ против 247 МБ
  • Процессы работают от root; PID 1 — npm, а не само приложение

Dockerfile по правилам: стадии, .dockerignore, USER, HEALTHCHECK

Сначала .dockerignore — список того, что не уходит в контекст сборки, синтаксис похож на .gitignore. Зависимости и dist собираются внутри образа, а секретам там не место.

.dockerignore
# Do not send to the builder: dependencies and build output are created inside the image
node_modules
dist
data
# Secrets and local files never belong in an image
.env
.env.*
.git
npm-debug.log*
Dockerfile*
.dockerignore
Синтаксис — шаблоны как в .gitignore.

Теперь многоэтапная сборка: каждая инструкция FROM начинает новую стадию, а COPY --from забирает из предыдущей только нужное. В итоговый образ попадают Node.js, продакшен-зависимости и скомпилированный код — без TypeScript и исходников.

Dockerfile
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24.21.0

# 1. Build: all dependencies, including TypeScript, compile src -> dist
FROM node:${NODE_VERSION}-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# 2. Production dependencies only, without devDependencies
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 3. Final image: Node.js, production node_modules and compiled code
FROM node:${NODE_VERSION}-alpine AS runtime
ENV NODE_ENV=production \
    DATA_DIR=/app/data
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /app/data && chown node:node /app/data
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "dist/server.js"]
Проверено: Docker 29.6, BuildKit; образ собирается, контейнер становится healthy.

ARG NODE_VERSION до FROM

Зачем она здесь
Версия Node.js в одном месте, закреплена точно: сборка через месяц даст тот же рантайм

FROM … AS build / deps / runtime

Зачем она здесь
Три стадии: сборка, продакшен-зависимости, итоговый образ; --target build собирает только первую

COPY package.json package-lock.json до COPY src

Зачем она здесь
Слой с npm ci пересобирается только при смене зависимостей, а не при каждой правке кода

npm ci --omit=dev

Зачем она здесь
Точная установка по package-lock.json без devDependencies

ENV NODE_ENV=production

Зачем она здесь
Переменная сохраняется в образе и видна приложению

USER node

Зачем она здесь
Пользователь node (uid 1000) уже есть в официальном образе; процесс работает без прав root

EXPOSE 3000

Зачем она здесь
Только документация: порт открывает -p при запуске

HEALTHCHECK

Зачем она здесь
Docker раз в 10 секунд вызывает /health; код выхода 0 — healthy, 1 — сбой, после 3 сбоев подряд — unhealthy

CMD ["node", …]

Зачем она здесь
Exec-форма: PID 1 — сам Node.js, сигналы доходят до приложения; не npm start
terminal
$ docker build -t notes-api:1.0 .
…
#9 [build 3/7] COPY package.json package-lock.json ./
#10 [build 4/7] RUN npm ci
#11 [deps 4/4] RUN npm ci --omit=dev
#11 0.960 added 65 packages, and audited 66 packages in 693ms
#10 1.585 added 78 packages, and audited 79 packages in 1s
#14 [build 6/7] COPY src ./src
#15 [build 7/7] RUN npm run build
#15 0.261 > notes-api@1.0.0 build
#15 0.261 > tsc
#16 [runtime 4/6] COPY --from=build /app/dist ./dist
#18 [runtime 6/6] RUN mkdir -p /app/data && chown node:node /app/data
#19 naming to docker.io/library/notes-api:1.0 done
Выдержка из вывода сборки: стадии build и deps выполняются параллельно.

Слои и кэш сборки

Каждая инструкция, меняющая файлы, даёт слой. При пересборке Docker проверяет, изменились ли инструкция и файлы, которые она копирует: если нет — слой берётся из кэша (CACHED). Как только слой изменился, все слои после него в этой стадии собираются заново. Поэтому редко меняющееся (зависимости) копируют раньше, часто меняющееся (код) — позже. Изменим одну строку в src/server.ts и пересоберём:

terminal
$ docker build -t notes-api:1.1 .
…
#10 [build 3/7] COPY package.json package-lock.json ./
#10 CACHED
#9 [build 4/7] RUN npm ci
#9 CACHED
#12 [build 6/7] COPY src ./src
#12 DONE 0.0s
#13 [build 7/7] RUN npm run build
#13 DONE 0.3s
#14 [deps 4/4] RUN npm ci --omit=dev
#14 CACHED
#15 [runtime 3/6] COPY --from=deps /app/node_modules ./node_modules
#15 CACHED
#16 [runtime 4/6] COPY --from=build /app/dist ./dist
#16 DONE 0.0s
Выдержка: npm ci в обеих стадиях — из кэша, заново собрались только код и слой dist.
terminal
$ docker history notes-api:1.0 --format 'table {{.CreatedBy}}\t{{.Size}}' | head -9
CREATED BY                                      SIZE
CMD ["node" "dist/server.js"]                   0B
HEALTHCHECK {Test:[CMD node -e fetch('http:/…   0B
EXPOSE [3000/tcp]                               0B
USER node                                       0B
RUN /bin/sh -c mkdir -p /app/data && chown n…   12.3kB
COPY package.json ./ # buildkit                 12.3kB
COPY /app/dist ./dist # buildkit                16.4kB
COPY /app/node_modules ./node_modules # buil…   4.44MB
Слои итогового образа сверху вниз; ниже — слои базового node:24.21.0-alpine.

Здесь видно, что из 247 МБ образа своих слоёв — около 4,5 МБ, остальное — базовый образ, который на сервере уже скачан и общий для всех приложений на нём. Документация советует пересобирать образы регулярно (docker build --pull) — образ это снимок, и обновления безопасности базового образа попадут в него только при пересборке.

Запуск: run, ps, logs, exec, stop

Запустим контейнер в фоне (-d), с именем и портом, и пройдём по основным командам.

terminal
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
14431bf467dc7af7235e5ea4abf38c7864f7775543f5499901030e23ae2c6f06
$ docker ps --filter name=notes
CONTAINER ID   IMAGE              COMMAND                  CREATED                  STATUS                                     PORTS                      NAMES
14431bf467dc   notes-api:1.0      "docker-entrypoint.s…"   Less than a second ago   Up Less than a second (health: starting)   127.0.0.1:8080->3000/tcp   notes
$ curl -s http://127.0.0.1:8080/health
{"status":"ok","uptime":0}$ sleep 8; docker ps --filter name=notes
CONTAINER ID   IMAGE              COMMAND                  CREATED         STATUS                   PORTS                      NAMES
14431bf467dc   notes-api:1.0      "docker-entrypoint.s…"   9 seconds ago   Up 8 seconds (healthy)   127.0.0.1:8080->3000/tcp   notes
$ docker logs notes
notes-api listening on port 3000 (NODE_ENV=production)
$ curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Buy coffee filters"}'
{"id":1,"text":"Buy coffee filters","createdAt":"2026-10-12T01:58:22.446Z"}$ docker exec notes whoami
node
$ docker exec notes ls -la /app
total 28
drwxr-xr-x    1 root     root          4096 Oct 12 01:57 .
drwxr-xr-x    1 root     root          4096 Oct 12 01:58 ..
drwxr-xr-x    1 node     node          4096 Oct 12 01:58 data
drwxr-xr-x    2 root     root          4096 Oct 12 01:57 dist
drwxr-xr-x   67 root     root          4096 Oct 12 01:57 node_modules
-rwxr-xr-x    1 root     root           329 Oct 12 01:56 package.json
$ docker exec notes cat /app/data/notes.json
[
  {
    "id": 1,
    "text": "Buy coffee filters",
    "createdAt": "2026-10-12T01:58:22.446Z"
  }
]$ docker exec notes ps -o pid,user,args
PID   USER     COMMAND
    1 node     {MainThread} node dist/server.js
   49 node     ps -o pid,user,args
$ docker inspect --format '{{.State.Health.Status}} {{len .State.Health.Log}}' notes
healthy 1
$ time docker stop notes
notes

real	0m0.487s
user	0m0.046s
sys	0m0.091s
$ docker logs notes --tail 2
notes-api listening on port 3000 (NODE_ENV=production)
SIGTERM received, shutting down
$ docker ps -a --filter name=notes
CONTAINER ID   IMAGE              COMMAND                  CREATED          STATUS                              PORTS     NAMES
14431bf467dc   notes-api:1.0      "docker-entrypoint.s…"   11 seconds ago   Exited (0) Less than a second ago             notes
$ docker rm notes
notes
Реальный вывод. В проверке у объектов был префикс kb-, в статье он убран.

docker run -d --name notes -p 127.0.0.1:8080:3000 образ

Что делает
Создать и запустить контейнер в фоне

docker ps, docker ps -a

Что делает
Работающие контейнеры; с -a — и остановленные

docker logs notes, docker logs -f notes

Что делает
Вывод приложения; -f — следить в реальном времени

docker exec notes команда, docker exec -it notes sh

Что делает
Выполнить команду внутри работающего контейнера; -it — интерактивная оболочка

docker stop notes

Что делает
SIGTERM, через 10 секунд — SIGKILL

docker rm notes, docker rm -f notes

Что делает
Удалить остановленный контейнер; -f — остановить и удалить

Почему остановка заняла полсекунды. docker stop отправляет главному процессу SIGTERM и ждёт 10 секунд, затем убивает его SIGKILL. Процесс с PID 1 в Linux особенный: сигналы, для которых нет обработчика, на него не действуют. В документации образа Node.js прямо сказано, что Node.js не рассчитан на роль PID 1. Вот что будет без обработчика:

terminal
$ docker run -d --name pid1 node:24.21.0-alpine node -e "setInterval(() => {}, 1000)"
7e0e9b2a5a1331b23b772ba1db38099350be54a98ca08d411a421b600561039b
$ time docker stop pid1
pid1

real	0m10.399s
user	0m0.061s
sys	0m0.091s
$ docker ps -a --filter name=pid1 --format '{{.Names}}: {{.Status}}'
pid1: Exited (137) Less than a second ago
$ docker rm pid1
pid1
$ docker run -d --init --name pid1-init node:24.21.0-alpine node -e "setInterval(() => {}, 1000)"
b334728e597d7e4eabd09cf15e46edc9b3a18b182c21673db9f04a491b9a5ce3
$ time docker stop pid1-init
pid1-init

real	0m0.339s
user	0m0.046s
sys	0m0.091s
$ docker rm pid1-init
pid1-init
Реальный вывод: без обработчика — 10 секунд ожидания и код 137 (128 + 9, SIGKILL); с --init — мгновенно.

Два решения: обрабатывать SIGTERM в приложении, как в нашем server.ts, или запускать контейнер с --init — тогда PID 1 становится маленький init-процесс, который передаёт сигналы приложению. Обработчик лучше: приложение успевает закрыть соединения и дописать данные.

Порты, переменные окружения и тома

Внутри контейнера своя сеть: приложение слушает порт 3000 в контейнере, а -p ХОСТ:КОНТЕЙНЕР пробрасывает к нему порт машины. Переменные окружения передаются через -e или --env-file. Файлы, записанные внутри контейнера, пропадают вместе с ним — для данных нужен том.

terminal
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
25d39d3a88a744f5c87357a3f681ab44b8e0a6544b893d30c0fcca8fa6a2370d
$ sleep 1; curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Call the supplier"}'
{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:24.178Z"}
$ docker rm -f notes
notes
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
f8605e984fe84d46c12abae2c5663e3f0eba212076956b73d1baa01a9d353f73
$ sleep 1; curl -s http://127.0.0.1:8080/api/notes
[]
$ docker rm -f notes
notes
$ docker volume create notes-data
notes-data
$ docker run -d --name notes -p 127.0.0.1:8080:3000 -v notes-data:/app/data notes-api:1.0
05589d4fff5d56e64bfe16409e085c0bb4b5164a84d0d8f621a751f45613db24
$ sleep 1; curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Call the supplier"}'
{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:28.587Z"}
$ docker rm -f notes
notes
$ docker run -d --name notes -p 127.0.0.1:8080:3000 -v notes-data:/app/data notes-api:1.0
523718889c0c184fd4e10b90cff736bedae3ad6befec681f5f986c0080a087ad
$ sleep 1; curl -s http://127.0.0.1:8080/api/notes
[{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:28.587Z"}]
$ docker volume ls --filter name=notes-data
DRIVER    VOLUME NAME
local     notes-data
$ docker volume inspect notes-data --format '{{.Name}} {{.Driver}} {{.Mountpoint}}'
notes-data local /var/lib/docker/volumes/notes-data/_data
$ docker run -d --name notes-2 -p 127.0.0.1:8080:3000 notes-api:1.0
ee5dd4dd6bd165af172b5f0c9d1716414492a49852293f4cfc789585f0665489
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint notes-2 (9e06e8e275da8ac1e377b36926f60ee4c9c68cd70b86c024a7d380d7c640f326): Bind for 127.0.0.1:8080 failed: port is already allocated

Run 'docker run --help' for more information
$ docker rm notes-2
notes-2
$ docker run -d --name notes-public -p 8090:3000 -e NODE_ENV=staging notes-api:1.0
c73d3880d9022ed756df223c9a44be3f1a1ded52075cd780c75ac2ba77687a11
$ docker ps --filter name=notes --format 'table {{.Names}}\t{{.Ports}}'
NAMES             PORTS
notes-public      0.0.0.0:8090->3000/tcp, [::]:8090->3000/tcp
notes             127.0.0.1:8080->3000/tcp
$ sleep 1; docker logs notes-public
notes-api listening on port 3000 (NODE_ENV=staging)
$ docker rm -f notes notes-public
notes
notes-public
Реальный вывод: без тома заметка пропала с контейнером, с томом notes-data — пережила пересоздание.
  • -p 8090:3000 без адреса публикует порт на всех интерфейсах (0.0.0.0 и [::]). Документация Docker прямо называет публикацию портов небезопасной по умолчанию: на сервере привязывайте к 127.0.0.1, а наружу выпускайте через reverse proxy
  • Один порт хоста — один контейнер: вторая попытка заняла 127.0.0.1:8080 и получила port is already allocated; созданный контейнер остался в статусе Created, его пришлось удалить
  • Именованный том переживает удаление контейнера; docker run --rm удаляет только анонимные тома
  • Пустой том при первом подключении заполняется содержимым папки из образа, включая владельца. Поэтому в Dockerfile мы создали /app/data и отдали её node — иначе приложение без root не сможет писать
  • Для разработки вместо тома подключают папку с хоста (bind mount, -v $(pwd)/src:/app/src): удобно, но привязано к файловой системе машины

Частые ошибки и упражнения

port is already allocated

Причина
Порт хоста занят другим контейнером или программой
Что сделать
Другой порт хоста или остановить того, кто занял; проверить docker ps

curl: (52) Empty reply или нет ответа при работающем контейнере

Причина
Приложение слушает 127.0.0.1 внутри контейнера
Что сделать
Внутри контейнера слушать 0.0.0.0 (Express по умолчанию так и делает)

EACCES: permission denied при записи

Причина
Папка в образе или томе принадлежит root, а процесс работает от node
Что сделать
Создать папку и сделать chown в Dockerfile до USER

Сборка каждый раз ставит зависимости заново

Причина
Код копируется до npm ci
Что сделать
Сначала package.json, package-lock.json и npm ci, затем код

docker stop ждёт 10 секунд

Причина
PID 1 не обрабатывает SIGTERM
Что сделать
Обработчик сигнала, --init, exec-форма CMD

Данные пропали после обновления

Причина
Писались в слой контейнера
Что сделать
Именованный том для всего, что должно жить дольше контейнера

Следующий шаг — Docker Compose: тот же образ, но вместе с PostgreSQL и Redis, сетью, томами и проверками здоровья в одном файле и одной командой.

Как проверено12 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    package.json, tsconfig.json, src/server.ts (Express 5.3.0, TypeScript 7.0.2)

    npm install во временном проекте, tsc без ошибок, локальный запуск на Node.js 24.18.0: /health, POST/GET /api/notes

  • запущено, работает

    docker run --rm node:24.21.0-alpine, docker image ls

    Docker Engine 29.6.2 (Docker Desktop, WSL 2, хранилище containerd), Git Bash с MSYS_NO_PATHCONV=1

  • запущено, работает

    Dockerfile.naive: сборка и запуск

    контекст 36.09MB, npm install заменил пакет TypeScript под Windows (added 1, removed 1), процессы от root, PID 1 = npm start, 354MB/93MB

  • запущено, работает

    .dockerignore + многоэтапный Dockerfile (build/deps/runtime, USER node, HEALTHCHECK на fetch)

    docker build --progress=plain, образ 247MB/62.3MB, контейнер health: starting → healthy, whoami = node; выдержки вывода сборки — строки из реального лога

  • запущено, работает

    кэш слоёв после правки src/server.ts, docker history

    npm ci в обеих стадиях CACHED, пересобраны build 6-7 и dist; тег 1.1 только для демонстрации, в статье server.ts исходный

  • запущено, работает

    docker run/ps/logs/exec/inspect/stop/rm

    реальный вывод; docker stop 0.49 с, в логе SIGTERM received; в статье префикс kb- у имён контейнеров/образа/тома убран, колонки таблиц выровнены на эти 3 символа

  • запущено, работает

    PID 1 без обработчика сигнала и с --init

    node -e setInterval: docker stop 10.4 с, Exited (137); с --init 0.34 с

  • запущено, работает

    тома и порты: без тома данные теряются, с именованным томом сохраняются, volume ls/inspect, port is already allocated, -p без адреса = 0.0.0.0 и [::], -e NODE_ENV

    реальный вывод, Docker Desktop 29.6.2

  • запущено, работает

    таблица ошибок: приложение слушает 127.0.0.1 внутри контейнера

    node:24.21.0-alpine с listen(3000,'127.0.0.1') и -p 127.0.0.1:8084:3000 → curl: (52) Empty reply from server (Docker Desktop; на Linux-хосте сообщение может отличаться)

  • запущено, работает

    упражнение modify (без chown)

    HTTP 500, в логах EACCES: permission denied, open '/app/data/notes.json', /app/data root:root

  • запущено, работает

    упражнение apply (финальная стадия alpine:3.24 + бинарник node)

    healthy, v24.21.0, npm: not found, 202MB/53.5MB

  • запущено, работает

    упражнение repeat

    шаги совпадают с проверенным прогоном основного сценария

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.