NNODE LOOP LABruntime observatoryNNEON · Articles in 90 languages
CONNECTING
v24.18.0linux/x64
Build → image → process
19

Docker: images, containers, and Compose

Learn how a Dockerfile becomes layered image content and how Compose runs connected, bounded containers.

PROCESS IDcurrent server
UPTIMEsince startup
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Timeline

READY
0 ms
Events will appear hereRun the selected scenario
#TIMESOURCEEVENT
Waiting for an experiment…
CHAPTER 19
DEEP DIVE · FROM BASICS TO CODE

Understanding: Docker: images, containers, and Compose

You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.

FIRST, IN PLAIN LANGUAGE

A Docker image resembles a sealed apartment blueprint: files and a startup command are defined in advance. A container is one occupied apartment created from that blueprint. The same image can run many times with different ports and configuration.

TECHNICAL FOUNDATION

Docker builds an image from Dockerfile layers and starts an isolated process group from it. The container uses the host kernel; namespaces isolate process, network, and filesystem views while cgroups account for and limit resources. Compose describes multiple related containers, networks, volumes, and runtime settings.

Why it mattersA container makes the application environment repeatable from CI to production, but it does not automatically make poor configuration safe. Senior engineers need to understand build context, cache, multi-stage images, PID 1, signals, persistent data, service DNS, healthchecks, and resource limits.
WHERE THE WORK RUNS
01DOCKERFILEinstructions · stages
02IMAGEread-only layers · metadata
03CONTAINERprocess · writable layer
04HOSTkernel · cgroups · network
01 · GLOSSARY

Terms used in this experiment

Understand the words first, then the execution order.

01

Image

An immutable template made of read-only layers and metadata such as its filesystem, default command, and environment.

02

Container

A running image instance with processes, a writable layer, a network namespace, and runtime configuration.

03

Dockerfile

A text recipe whose instructions define build stages, filesystem layers, and image metadata.

04

Build context

The files available to COPY or ADD; .dockerignore excludes unnecessary or sensitive files before context transfer.

05

Layer

A reusable filesystem or metadata change whose build cache depends on the instruction and its inputs.

06

Registry

Storage from which versioned images are pushed and pulled by repository, tag, or digest.

07

Volume

Data with a lifecycle outside a container writable layer, used for persistent state.

08

PID 1

The first process inside a container, responsible for receiving termination signals and reaping finished children.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Prepare the build context

    The client reads a directory while .dockerignore excludes node_modules, Git data, build output, and secrets.

  2. 02
    Execute the Dockerfile

    The builder resolves base images and evaluates each required build stage.

  3. 03
    Reuse cache

    An unchanged instruction with unchanged inputs can reuse a layer, so dependency manifests are copied before source.

  4. 04
    Produce the runtime image

    Multi-stage COPY transfers only the standalone artifact without the compiler, caches, or development tools.

  5. 05
    Create a container

    The runtime adds a writable layer, environment, limits, mounts, and a network namespace over the image.

  6. 06
    Start the main process

    CMD or ENTRYPOINT selects the process, USER reduces privilege, and init helps signal forwarding.

  7. 07
    Connect services

    Compose supplies a network and service DNS names, so the app connects to postgres rather than localhost.

  8. 08
    Observe lifecycle

    Health status, restart policy, logs, and graceful shutdown expose process readiness and termination.

03 · CONTEXT

Where the result needs context

These details explain why similar code can sometimes produce a different trace.

01

An image is not a virtual machine

A container does not boot another kernel, so image platform and host architecture must be compatible.

02

EXPOSE does not publish a port

EXPOSE documents a container port; docker run -p or Compose ports creates a host mapping.

03

localhost is local to the current container

From the app container, localhost is not the Postgres container; use the postgres service name.

04

ENV becomes runtime metadata

Do not preserve build secrets through ARG, ENV, or COPY because image history and layers can expose them.

05

depends_on is not a migration system

A healthy dependency is only a probe result; connection retries and controlled schema migrations remain necessary.

06

Container filesystems are usually disposable

A writable layer disappears with the container; database state belongs in a volume or managed database.

07

A tag can move

latest and mutable version tags may resolve to different content; a digest identifies one exact image.

01
Theory

First understand which parts of Node participate in execution.

02
Simplified code

Then remove instrumentation and focus on the central mechanism.

03
Runtime code

Finally match the model to the code that produces the live trace.

04 · Simplified code

A minimal model without instrumentation

src/demos.js · educational snippetJavaScript
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 code

The complete code executed by the scenario

This is not an alternative example: these are the functions and files used by the Run button.

ACTUAL SOURCE

The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.

src/infrastructure-lab.js
infrastructure266 lines
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',
  );
}

The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.

06 · RECIPES

Practical patterns worth keeping nearby

Compare the goal, code, and caveats instead of memorizing syntax without a model.

01

Build and run an image

Create a local image and a container with a published 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 assigns a local repository and tag.
  • --rm removes the stopped container, not the image.
  • --init adds a minimal init process as PID 1.
02

COPY order for cache

Avoid reinstalling dependencies after every source edit.

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

COPY . .
RUN npm run build
  • npm ci reproduces the lockfile without changing it.
  • A source change invalidates only the later layers.
03

Multi-stage runtime

Keep the build toolchain out of the 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"]
  • The final image begins at a new FROM.
  • Only a selected artifact crosses from the build stage.
04

Compose service DNS

Connect the app and PostgreSQL inside a 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 is the hostname derived from the service name.
  • A real deployment must not commit the password in the Compose file.
05

Inspect runtime state

Distinguish an image, a running container, and its logs.

docker image ls
docker compose ps
docker compose logs -f node-loop-lab
docker inspect node-loop-lab
  • ps exposes status and published ports.
  • logs -f follows stdout and stderr.
  • inspect returns low-level JSON configuration.
06

Graceful Node shutdown

Drain traffic and close resources before forced termination.

process.once('SIGTERM', async () => {
  server.close();
  await databasePool.end();
  process.exitCode = 0;
});
  • docker stop sends SIGTERM before SIGKILL.
  • The process must finish active requests within its grace period.
07 · PRODUCTION CASES

How a learning mistake becomes an incident

A realistic service: the original code, observable failure, corrected implementation, and why the correction works.

CASE 01

The production image contains dev dependencies, a secret, and a root process

A team builds its Nest/Next application in one stage, copies the entire working directory, and passes a registry token through ENV. The same large image runs in production as root.

INCIDENT CONTEXT

Any build-context file can enter a layer, the secret remains in image metadata or history, and compilers plus dev dependencies increase size and attack surface. A compromised root process has needless privilege.

BEFOREPROBLEMATIC IMPLEMENTATION
FROM node:24
WORKDIR /app

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

EXPOSE 3000
CMD npm run start

One stage mixes supply, build, and runtime concerns. COPY depends on the entire context, shell-form CMD adds an intermediate process, and the container uses the default root user.

AFTERCORRECTED IMPLEMENTATION
# 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"]

The secret mount exists only during RUN, the lockfile gives reproducible installation, multi-stage COPY moves a minimal artifact, and exec-form CMD starts Node directly under an unprivileged user.

FUNCTIONS AND CONSTRUCTS

What the unfamiliar calls from both code samples actually do.

FROM ... AS stage
Starts a named build stage. The final image contains only the final ancestry and artifacts copied explicitly from other stages.
RUN --mount=type=secret
BuildKit temporarily mounts a secret for one RUN instruction without preserving it as ENV or a regular filesystem layer.
npm ci
Installs the exact package-lock.json tree and fails when package manifests are out of synchronization.
COPY --from=build
Transfers selected files from another stage instead of including the entire build environment in the runtime image.
COPY --chown=node:node
Assigns ownership while copying so the unprivileged runtime user can read the necessary artifacts.
CMD ["node", "server.js"]
Exec form starts Node without a shell wrapper, allowing signals to reach the application process directly.
WHY THE CORRECTION WORKS

Treat the image as a production artifact: pin the base, scan vulnerabilities and an SBOM, keep credentials out of layers, and rebuild regularly even when application code has not changed.

WHAT PRODUCTION SHOWED
  • The image is hundreds of megabytes larger and contains test or build packages.
  • docker history or inspect exposes a sensitive ENV value.
  • A container check reports uid=0.
08 · DO NOT CONFUSE

Common misconceptions

The myth is on the left; the accurate model is on the right.

MYTH

A container contains a separate operating system.

ACTUALLY

It contains user-space files but shares the host kernel.

MYTH

COPY . . copies only the application.

ACTUALLY

Without .dockerignore, secrets, Git data, local dependencies, and large junk can enter the context.

MYTH

A successfully built image is production-ready.

ACTUALLY

Production also needs a non-root user, lean runtime, signals, health, limits, logs, and base-image updates.

MYTH

PostgreSQL data can live in the container.

ACTUALLY

Containers are replaceable; persistent state needs a volume or external storage.

MYTH

HEALTHCHECK guarantees product availability.

ACTUALLY

It proves only the selected signal, which may be too weak or coupled to too many dependencies.

09 · SELF-CHECK

Explain it in your own words

If you can explain the answer without quoting documentation, your mental model is starting to take shape.

  1. How does an image differ from a container?
  2. Why does a source edit not have to rerun npm ci?
  3. Why does EXPOSE 3000 not open a host port?
  4. Why does DATABASE_URL use postgres rather than localhost?
  5. Which files belong in .dockerignore?
  6. Why use a production stage and USER node?
  7. What happens to writable-layer data when a container is removed?