NNODE LOOP LABruntime observatoryNNEON · Статьи на 90 языках
CONNECTING
v24.18.0linux/x64
Build → image → process
19

Docker: images, containers и Compose

Разберите, как Dockerfile превращается в layered image и как Compose запускает связанные containers с limits и healthchecks.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Временная шкала

ГОТОВ
0 ms
События появятся здесьЗапустите выбранный сценарий
#ВРЕМЯИСТОЧНИКСОБЫТИЕ
Ожидаю запуск эксперимента…
ГЛАВА 19
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Docker: images, containers и Compose

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Docker image похож на запечатанный шаблон квартиры: в нём заранее определены файлы и команда запуска. Container — конкретная квартира, созданная по шаблону и уже используемая жильцом-процессом. Один image можно запускать много раз с разными портами и настройками.

ТЕХНИЧЕСКАЯ ОСНОВА

Docker собирает image из слоёв по Dockerfile и запускает из него изолированную группу процессов. Контейнер использует kernel хоста, а namespaces отделяют процессы, сеть и filesystem view; cgroups учитывают и ограничивают ресурсы. Compose описывает несколько связанных containers, networks, volumes и runtime settings.

Зачем это знатьКонтейнер фиксирует окружение приложения от CI до production, но не делает плохую конфигурацию безопасной автоматически. Для senior-разработчика важно понимать build context, cache, multi-stage images, PID 1, сигналы, persistent data, service DNS, healthchecks и resource limits.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01DOCKERFILEinstructions · stages
02IMAGEread-only layers · metadata
03CONTAINERprocess · writable layer
04HOSTkernel · cgroups · network
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Image

Неизменяемый шаблон из read-only layers и metadata: filesystem, default command, environment и другие настройки.

02

Container

Запущенный экземпляр image: один или несколько процессов, writable layer, network namespace и runtime configuration.

03

Dockerfile

Текстовый рецепт сборки image. Инструкции создают стадии, filesystem layers и metadata.

04

Build context

Набор файлов, доступный builder-у для COPY/ADD. .dockerignore исключает лишнее и чувствительное до отправки context.

05

Layer

Переиспользуемое изменение filesystem или metadata image. Cache зависит от инструкции и её inputs.

06

Registry

Хранилище версионированных images, откуда их push-ят и pull-ят по repository:tag или digest.

07

Volume

Данные с жизненным циклом вне writable layer container; применяются для persistent state.

08

PID 1

Первый process внутри container, который должен получать сигналы завершения и reap-ить завершившиеся дочерние processes.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Подготовьте build context

    Docker client читает выбранную директорию; .dockerignore заранее исключает node_modules, .git, build output и секреты.

  2. 02
    Выполните Dockerfile

    Builder разрешает base images и последовательно вычисляет инструкции каждой необходимой stage.

  3. 03
    Переиспользуйте cache

    Если инструкция и её inputs не изменились, готовый слой используется повторно. Поэтому manifests dependencies копируют раньше исходников.

  4. 04
    Соберите runtime image

    Multi-stage COPY переносит только standalone artifact из build stage, не включая compiler, cache и исходные dev tools.

  5. 05
    Создайте container

    Runtime добавляет writable layer, environment, limits, mounts и network namespace поверх immutable image.

  6. 06
    Запустите главный process

    CMD/ENTRYPOINT определяют process, USER снижает привилегии, init помогает корректно передавать signals.

  7. 07
    Подключите сервисы

    Compose создаёт network и DNS-имена services; приложение обращается к postgres:5432, а не к localhost.

  8. 08
    Наблюдайте lifecycle

    Healthcheck, restart policy, logs и graceful shutdown показывают, готов ли process и как он завершается.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Image не является виртуальной машиной

Container не загружает отдельный kernel. Изоляция строится вокруг процессов хоста, поэтому image должен соответствовать OS/architecture platform.

02

EXPOSE не публикует порт

EXPOSE документирует container port. Реальную связь host:container создают docker run -p или Compose ports.

03

localhost всегда локален текущему container

Из app container localhost не указывает на postgres container. В Compose используют service name postgres.

04

ENV попадает в runtime metadata

Build secret нельзя сохранять через ARG/ENV или COPY: он может остаться в history/layers. Секрет передают runtime-механизмом или BuildKit secret mount.

05

depends_on не является миграцией

Healthy dependency означает лишь успешный probe. Приложение всё равно должно retry-ить connections, а schema migrations выполняются отдельным контролируемым шагом.

06

Container filesystem обычно одноразовый

Writable layer исчезает вместе с container. PostgreSQL state требует volume или внешнюю managed database.

07

Tag может перемещаться

latest и даже version tag могут указывать на другой image. Digest идентифицирует конкретное содержимое и делает deployment воспроизводимее.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine AS runtime
WORKDIR /app
COPY --from=build /app/.next/standalone ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/infrastructure-lab.js
infrastructure266 строк
const pause = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

export const dockerfileExample = `# syntax=docker/dockerfile:1
FROM node:24-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM dependencies AS build
COPY . .
RUN npm run build

FROM node:24-alpine AS runtime
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY --chown=node:node --from=build /app/.next/standalone ./
COPY --chown=node:node --from=build /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r => { if (!r.ok) process.exit(1) })"
CMD ["node", "server.js"]`;

export const composeExample = `services:
  app:
    build:
      context: .
      target: runtime
    init: true
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DATABASE_URL: postgresql://app:password@postgres:5432/app
    depends_on:
      postgres:
        condition: service_healthy
    mem_limit: 2g
    pids_limit: 128
    restart: unless-stopped

  postgres:
    image: postgres:18-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 12`;

export const kubernetesManifestExample = `apiVersion: apps/v1
kind: Deployment
metadata:
  name: node-loop-lab
spec:
  replicas: 3
  strategy:
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: node-loop-lab
  template:
    metadata:
      labels:
        app: node-loop-lab
    spec:
      containers:
        - name: app
          image: ghcr.io/example/node-loop-lab:1.0.0
          ports:
            - name: http
              containerPort: 3000
          readinessProbe:
            httpGet:
              path: /api/health
              port: http
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /api/health
              port: http
            periodSeconds: 10
            failureThreshold: 3
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: node-loop-lab
spec:
  selector:
    app: node-loop-lab
  ports:
    - name: http
      port: 80
      targetPort: http`;

function dockerStages(source) {
  const stages = [];
  for (const line of source.split('\n')) {
    const match = line.match(/^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i);
    if (match) {
      stages.push({
        image: match[1],
        name: match[2] ?? `stage-${stages.length + 1}`,
      });
    }
  }
  return stages;
}

export async function dockerBuildAndRun(emit) {
  const stages = dockerStages(dockerfileExample);

  emit(
    'build-context',
    'context',
    'Docker client собирает build context; .dockerignore исключает node_modules, .git и секреты',
  );
  await pause(15);

  emit(
    'dockerfile',
    'parse',
    `Dockerfile описывает ${stages.length} стадии: ${stages.map((stage) => stage.name).join(' → ')}`,
  );

  for (const stage of stages) {
    await pause(15);
    emit(
      'buildkit',
      'stage',
      `BuildKit строит stage ${stage.name} из immutable base image ${stage.image}`,
    );
  }

  emit(
    'cache',
    'layer',
    'COPY package*.json расположен до COPY исходников: изменение кода не инвалидирует слой npm ci',
  );
  await pause(15);
  emit(
    'image',
    'artifact',
    'Runtime image получает standalone build, но не исходный build toolchain',
  );
  await pause(15);
  emit(
    'container',
    'process',
    'Container запускает node server.js как USER node; init передаёт сигналы и убирает zombie processes',
  );
  await pause(15);
  emit(
    'network',
    'publish',
    'Port mapping 127.0.0.1:3000:3000 публикует container port только на loopback хоста',
  );
  await pause(15);
  emit(
    'health',
    'probe',
    'Healthcheck проверяет /api/health; healthy не означает, что все внешние зависимости доступны',
  );
  emit(
    'result',
    'summary',
    'Image — неизменяемый шаблон; container — запущенный process с writable layer и runtime configuration',
  );
}

function readyPods(pods) {
  return pods.filter((pod) => pod.ready);
}

export async function kubernetesReconciliation(emit) {
  const desiredReplicas = 3;
  let generation = 1;
  let pods = [
    { name: 'node-loop-lab-old-1', version: '1.0.0', ready: true },
  ];

  emit(
    'api-server',
    'desired-state',
    `Deployment принят: desired replicas=${desiredReplicas}, image=1.0.0`,
  );
  await pause(15);

  while (pods.length < desiredReplicas) {
    const pod = {
      name: `node-loop-lab-old-${pods.length + 1}`,
      version: '1.0.0',
      ready: false,
    };
    pods.push(pod);
    emit(
      'deployment-controller',
      'reconcile',
      `Actual=${pods.length - 1}, desired=${desiredReplicas}: ReplicaSet создаёт ${pod.name}`,
    );
    await pause(15);
    pod.ready = true;
    emit(
      'kubelet',
      'readiness',
      `${pod.name} прошёл readinessProbe и добавлен в endpoints Service`,
    );
  }

  emit(
    'service',
    'routing',
    `Service выбирает по label ${readyPods(pods).length} ready Pods из ${pods.length}`,
  );
  await pause(15);

  pods[1].ready = false;
  emit(
    'readiness',
    'traffic',
    `${pods[1].name} стал NotReady: container продолжает работать, но Service исключил его из трафика`,
  );
  await pause(15);
  pods[1].ready = true;

  generation += 1;
  const newPod = {
    name: `node-loop-lab-new-${generation}`,
    version: '1.1.0',
    ready: false,
  };
  pods.push(newPod);
  emit(
    'rolling-update',
    'surge',
    `maxSurge=1: создан ${newPod.name}, старые ready Pods пока обслуживают трафик`,
  );
  await pause(15);
  newPod.ready = true;
  pods = pods.filter((pod) => pod.name !== 'node-loop-lab-old-1');
  emit(
    'rolling-update',
    'replace',
    'Новый Pod стал Ready; controller удалил один старый Pod без снижения ready replicas',
  );

  emit(
    'scheduler',
    'resources',
    'Scheduler размещает Pod по requests; limits ограничивают runtime, но не резервируют дополнительный ресурс',
  );
  emit(
    'result',
    'summary',
    'Kubernetes непрерывно сравнивает desired и actual state; controller исправляет расхождение, а Service маршрутизирует только Ready Pods',
  );
}

Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

Собрать и запустить image

Создать локальный image и container с опубликованным HTTP port.

docker build -t node-loop-lab:local .
docker run --rm --init \
  -p 127.0.0.1:3000:3000 \
  --memory=2g --pids-limit=128 \
  node-loop-lab:local
  • -t присваивает локальное repository:tag.
  • --rm удаляет остановленный container, но не image.
  • --init добавляет минимальный init как PID 1.
02

Порядок COPY для cache

Не переустанавливать dependencies после каждого изменения src.

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

COPY . .
RUN npm run build
  • npm ci воспроизводит lockfile и не изменяет его.
  • Изменение source инвалидирует только последующие layers.
03

Multi-stage runtime

Не переносить build toolchain в production image.

FROM node:24-alpine AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM node:24-alpine AS runtime
WORKDIR /app
COPY --from=build /app/.next/standalone ./
CMD ["node", "server.js"]
  • Финальный image начинается с нового FROM.
  • В runtime попадает выбранный artifact, а не весь build stage.
04

Compose service DNS

Соединить app и PostgreSQL внутри Compose network.

services:
  app:
    environment:
      DATABASE_URL: postgresql://app:secret@postgres:5432/app
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:18-alpine
  • postgres — DNS hostname из имени service.
  • Пароль в реальном deployment не коммитят в compose file.
05

Посмотреть состояние

Отличить image, запущенный container и его logs.

docker image ls
docker compose ps
docker compose logs -f node-loop-lab
docker inspect node-loop-lab
  • ps показывает runtime status и published ports.
  • logs -f продолжает читать stdout/stderr.
  • inspect возвращает низкоуровневую JSON-конфигурацию.
06

Graceful shutdown Node

Перестать принимать трафик и закрыть ресурсы до SIGKILL.

process.once('SIGTERM', async () => {
  server.close();
  await databasePool.end();
  process.exitCode = 0;
});
  • docker stop сначала отправляет SIGTERM.
  • Process должен завершить активные requests в пределах grace period.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

Production image содержит dev dependencies, секрет и root process

Команда собирает Nest/Next приложение одним stage, копирует всю рабочую директорию и передаёт registry token через ENV. Тот же тяжёлый image запускается в production от root.

КОНТЕКСТ ИНЦИДЕНТА

Любой файл из build context может попасть в layer, секрет остаётся в image metadata/history, а compiler и dev dependencies увеличивают размер и поверхность атаки. Захваченный root process получает лишние права.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
FROM node:24
WORKDIR /app

COPY . .
ENV NPM_TOKEN=production-secret
RUN npm install

EXPOSE 3000
CMD npm run start

Один stage смешивает supply, build и runtime. COPY зависит от всего context, shell-форма CMD добавляет промежуточный process, а container запускается с default root user.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
# syntax=docker/dockerfile:1
FROM node:24-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

FROM dependencies AS build
COPY . .
RUN npm run build

FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --chown=node:node --from=build \
  /app/.next/standalone ./
COPY --chown=node:node --from=build \
  /app/.next/static ./.next/static
USER node
CMD ["node", "server.js"]

Secret mount существует только во время RUN, lockfile даёт воспроизводимую установку, multi-stage переносит минимальный artifact, а exec CMD запускает Node напрямую под непривилегированным user.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

FROM ... AS stage
Начинает именованную build stage. Финальный image содержит только ancestry последней stage и явно скопированные artifacts.
RUN --mount=type=secret
BuildKit временно монтирует secret на время одной RUN-инструкции, не сохраняя его как ENV или обычный filesystem layer.
npm ci
Устанавливает точное дерево из package-lock.json и завершается ошибкой при рассинхронизации manifests.
COPY --from=build
Переносит выбранные файлы из предыдущей stage вместо включения всего build environment в runtime image.
COPY --chown=node:node
Сразу назначает владельца copied files, чтобы непривилегированный runtime user мог читать необходимые artifacts.
CMD ["node", "server.js"]
Exec-форма запускает Node без shell-wrapper, поэтому signal доходит до application process напрямую.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Image нужно считать production artifact и проверять отдельно: pin base image, сканировать vulnerabilities/SBOM, не хранить credentials в layers и регулярно пересобирать даже без изменения application code.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Image занимает сотни лишних мегабайт и содержит test/build packages.
  • docker history или inspect показывает чувствительное ENV.
  • Проверка container сообщает uid=0.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Container содержит отдельную операционную систему.

НА САМОМ ДЕЛЕ

В image есть user-space files, но kernel используется от host.

МИФ

COPY . . безопасно копирует только нужный код.

НА САМОМ ДЕЛЕ

Без .dockerignore в context могут попасть секреты, .git, локальные зависимости и большой мусор.

МИФ

Если image собрался, он production-ready.

НА САМОМ ДЕЛЕ

Нужны non-root USER, минимальный runtime, graceful shutdown, health signal, limits, logs и обновление base image.

МИФ

Данные PostgreSQL можно хранить внутри container.

НА САМОМ ДЕЛЕ

Container заменяем; persistent state должен жить в volume или внешнем storage.

МИФ

HEALTHCHECK гарантирует доступность всего продукта.

НА САМОМ ДЕЛЕ

Probe проверяет только выбранный сигнал и может быть слишком слабым либо, наоборот, зависеть от всего мира.

09 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Чем image отличается от container?
  2. Почему изменение src не обязано заново выполнять npm ci?
  3. Почему EXPOSE 3000 не открывает порт на host?
  4. Почему DATABASE_URL использует postgres, а не localhost?
  5. Какие файлы нужно исключить через .dockerignore?
  6. Зачем production stage и USER node?
  7. Что произойдёт с данными writable layer после удаления container?