Docker: images, containers и Compose
Разберите, как Dockerfile превращается в layered image и как Compose запускает связанные containers с limits и healthchecks.
Временная шкала
Разбираем: Docker: images, containers и Compose
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Docker image похож на запечатанный шаблон квартиры: в нём заранее определены файлы и команда запуска. Container — конкретная квартира, созданная по шаблону и уже используемая жильцом-процессом. Один image можно запускать много раз с разными портами и настройками.
Docker собирает image из слоёв по Dockerfile и запускает из него изолированную группу процессов. Контейнер использует kernel хоста, а namespaces отделяют процессы, сеть и filesystem view; cgroups учитывают и ограничивают ресурсы. Compose описывает несколько связанных containers, networks, volumes и runtime settings.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Image
Неизменяемый шаблон из read-only layers и metadata: filesystem, default command, environment и другие настройки.
Container
Запущенный экземпляр image: один или несколько процессов, writable layer, network namespace и runtime configuration.
Dockerfile
Текстовый рецепт сборки image. Инструкции создают стадии, filesystem layers и metadata.
Build context
Набор файлов, доступный builder-у для COPY/ADD. .dockerignore исключает лишнее и чувствительное до отправки context.
Layer
Переиспользуемое изменение filesystem или metadata image. Cache зависит от инструкции и её inputs.
Registry
Хранилище версионированных images, откуда их push-ят и pull-ят по repository:tag или digest.
Volume
Данные с жизненным циклом вне writable layer container; применяются для persistent state.
PID 1
Первый process внутри container, который должен получать сигналы завершения и reap-ить завершившиеся дочерние processes.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Подготовьте build context
Docker client читает выбранную директорию; .dockerignore заранее исключает node_modules, .git, build output и секреты.
- 02Выполните Dockerfile
Builder разрешает base images и последовательно вычисляет инструкции каждой необходимой stage.
- 03Переиспользуйте cache
Если инструкция и её inputs не изменились, готовый слой используется повторно. Поэтому manifests dependencies копируют раньше исходников.
- 04Соберите runtime image
Multi-stage COPY переносит только standalone artifact из build stage, не включая compiler, cache и исходные dev tools.
- 05Создайте container
Runtime добавляет writable layer, environment, limits, mounts и network namespace поверх immutable image.
- 06Запустите главный process
CMD/ENTRYPOINT определяют process, USER снижает привилегии, init помогает корректно передавать signals.
- 07Подключите сервисы
Compose создаёт network и DNS-имена services; приложение обращается к postgres:5432, а не к localhost.
- 08Наблюдайте lifecycle
Healthcheck, restart policy, logs и graceful shutdown показывают, готов ли process и как он завершается.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Image не является виртуальной машиной
Container не загружает отдельный kernel. Изоляция строится вокруг процессов хоста, поэтому image должен соответствовать OS/architecture platform.
EXPOSE не публикует порт
EXPOSE документирует container port. Реальную связь host:container создают docker run -p или Compose ports.
localhost всегда локален текущему container
Из app container localhost не указывает на postgres container. В Compose используют service name postgres.
ENV попадает в runtime metadata
Build secret нельзя сохранять через ARG/ENV или COPY: он может остаться в history/layers. Секрет передают runtime-механизмом или BuildKit secret mount.
depends_on не является миграцией
Healthy dependency означает лишь успешный probe. Приложение всё равно должно retry-ить connections, а schema migrations выполняются отдельным контролируемым шагом.
Container filesystem обычно одноразовый
Writable layer исчезает вместе с container. PostgreSQL state требует volume или внешнюю managed database.
Tag может перемещаться
latest и даже version tag могут указывать на другой image. Digest идентифицирует конкретное содержимое и делает deployment воспроизводимее.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
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"]Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
Собрать и запустить 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.
Порядок COPY для cache
Не переустанавливать dependencies после каждого изменения src.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build- npm ci воспроизводит lockfile и не изменяет его.
- Изменение source инвалидирует только последующие layers.
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.
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.
Посмотреть состояние
Отличить 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-конфигурацию.
Graceful shutdown Node
Перестать принимать трафик и закрыть ресурсы до SIGKILL.
process.once('SIGTERM', async () => {
server.close();
await databasePool.end();
process.exitCode = 0;
});- docker stop сначала отправляет SIGTERM.
- Process должен завершить активные requests в пределах grace period.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
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 напрямую.
Популярные заблуждения
Миф слева, корректная модель справа.
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 проверяет только выбранный сигнал и может быть слишком слабым либо, наоборот, зависеть от всего мира.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Чем image отличается от container?
- Почему изменение src не обязано заново выполнять npm ci?
- Почему EXPOSE 3000 не открывает порт на host?
- Почему DATABASE_URL использует postgres, а не localhost?
- Какие файлы нужно исключить через .dockerignore?
- Зачем production stage и USER node?
- Что произойдёт с данными writable layer после удаления container?